ADR 0009 — TimerBll 拆出獨立排程 exe(TsERP.SchedulerWorker)+ Windows 工作排程器¶
- 日期:2026-04-28
- 狀態:Accepted(部分被 ADR 0015 取代——
TWORK_SKD_NOTIFYEVENT已拆成兩支單一職責 SP) - 決策者:使用者
Context¶
LogicBll/Timer/TimerBll.cs 原本以 DispatcherTimer 形式內嵌在 WPF App 主流程裡,每 5 分鐘觸發一次:
- 行事曆事件通知(Mailgun 寄信、SMS)
- AutoPilot 自動化批次(依事件設定,用 reflection 跑指定 BLL)
這個架構有幾個明顯問題:
- 必須有人開著 ERP 才會跑:
DispatcherTimer綁定在 UI thread,App 沒啟動就完全沒觸發,週末/下班時段通知與批次都會中斷 - 目前實際上已被註解掉沒在跑:因為早期版本曾發生 timer 跟主畫面互卡的問題,被人為停用後就一直沒恢復
- 多人開機重複觸發:若同時多個使用者開 ERP,同一個事件可能被多個 client 各跑一次 (
已通知欄位被重複寫入) - 狀態不夠細:原本
已完成只有Y/F/ 空,無法分辨「正在跑」跟「已跑完」,異常結束會留下殭屍紀錄 - AutoPilot 與 ERP 生命週期綁死:每次 ERP 重啟、登出、關閉,AutoPilot 也跟著中斷
過去討論過幾次「移到 server side」但都沒成案,這次趁 Mailgun 與 AutoPilot 都需要強化通知與重試保證的時機,一次拆乾淨。
Decision¶
把通知與 AutoPilot 排程邏輯從 WPF App 拆出來,做成獨立 console exe TsERP.SchedulerWorker,由 Windows 工作排程器(schtasks)每 5 分鐘觸發一次。原 TimerBll.cs 保留不刪,作為過渡期 fallback 與測試用。
D1:用獨立 console exe 而非 Worker Service 常駐¶
TsERP.SchedulerWorker 是 net8.0-windows、UseWPF=true、OutputType=Exe 的 console 程式,每次由 schtasks 拉起來跑一回 RunOnceAsync() 後結束。
理由:
- schtasks 已經做掉「啟動/停止/失敗 retry/log」,自己寫 Worker Service 等於重造輪子
- 跑完即退能避免 long-running process 的記憶體洩漏與 GC 問題
- 排查問題時
schtasks /Query看得到歷史執行紀錄,比常駐 service 的 log 更直觀 - 每次冷啟動 ~3 秒,5 分鐘觸發一次完全可接受
UseWPF=true是因為 LogicBll 和 Common 仍依賴一些 WPF 型別(DispatcherObject、附加屬性),暫時拖一票上去比起把 LogicBll 整個解耦更務實
D2:用 Global\TsERP_Scheduler Named Mutex 防重疊¶
Program.cs 一進來先搶 Global\TsERP_Scheduler mutex,搶不到就立刻退出(exit code 2)。schtasks 也設「不允許同時執行多個實例」雙保險。
理由:
- 上一輪跑超過 5 分鐘時,下一輪不會再進來,避免並發寫同一筆事件
- Global named mutex 跨 session 有效,即使從不同帳號觸發也能擋
D3:DB 層加「執行中」狀態 + 殭屍回收¶
twork_skd_eventitem 加 執行開始時間 datetime NULL 欄位,已通知 / 已完成 多一個 'X'(執行中)狀態。SP TWORK_SKD_NOTIFYEVENT 改:
- 加
TOP 20+ORDER BY ASC:每次只拿 20 筆,避免單次跑爆 - QueryType=2 移除原本 1 小時時間窗:用
已完成 NOT IN ('Y','F')過濾,加ZombieTimeoutMinutes殭屍回收(執行開始時間超過閾值還停在'X'的,視為前一輪 crash 重新拉回) - 失敗事件(
'F')不再自動重跑,需要管理員手動把狀態改回N或空
新增 SP TWORK_SKD_UPDATE_EVENT_STATUS:原子化更新狀態 + 時間戳(CalendarBll 新多載呼叫)。
理由:
'X'狀態 +執行開始時間讓殭屍回收有依據,crash 後 N 分鐘自動重跑TOP 20把單次 batch size 上限壓住,避免某次積太多事件時把 server 打滿- 失敗事件不自動重跑,避免「同一封壞掉的信無限重發」之類的雪崩
D4:CalendarBll 加多載,舊簽章保留¶
CalendarBll 加多載 Update已通知(pkid, status, DateTime?) 跟 Update已完成(pkid, status, DateTime?),支援 'X' 狀態 + 時間戳。原 2-arg 簽章保留,避免影響其他呼叫端。
Consequences¶
正面¶
- 無人值守:ERP 沒開、使用者下班、週末,通知與 AutoPilot 都照跑
- 去除多人重複觸發:只剩一個排程入口,單純多了
- 可獨立部署、獨立升級:排程器邏輯改了不用重發整個 ERP client
- AutoPilot 解耦:未來要把 AutoPilot 換更穩的排程器(Hangfire / Quartz),只需動 SchedulerWorker 一個專案
- 狀態可觀測:殭屍紀錄能被回收,crash 後不會永遠卡住
負面 / 風險¶
- 多一個部署單位:以前只要更新 ERP,現在還要更新 SchedulerWorker;版本 drift 風險
- 發布 size 大(300MB+):因
UseWPF=true且依賴 LogicBll,把 WPF runtime 整個拖下去;每次升級檔案大 - 服務帳號管理:要建專屬服務帳號並加入本機 Administrators(因部分 BLL 動 registry / 寫系統路徑),帳號權限與密碼輪替要納入維運程序
- DB schema 變更不可逆:新增的
'X'狀態跟執行開始時間欄位,回退時要小心舊版 ERP 看不懂這些值 - 失敗事件需要人工介入:以前壞了會自動重試,現在會卡在
'F',第一次上線要監控管理員是否知道要去看
影響範圍(本次已完成)¶
TsERP.SchedulerWorker/(新專案)TsERP.SchedulerWorker.csprojProgram.cs、SchedulerJob.cs、SchedulerSettings.cs、SchedulerUser.csappsettings.jsonLogicBll/Calendar/CalendarBll.cs:新增 3-arg 多載ViewModel/PageControl/MainWindow/MainWindowBtnTimerViewModel.cs:拿掉TimerBll欄位(VM 殼保留)TsERP.sln:加入新專案- DB:
twork_skd_eventitem加欄位、TWORK_SKD_NOTIFYEVENT改 SP、新增TWORK_SKD_UPDATE_EVENT_STATUS
Migration¶
- DBA 端:對每個 darb DB 跑
ALTER TABLE twork_skd_eventitem ADD 執行開始時間 datetime NULLALTER PROCEDURE TWORK_SKD_NOTIFYEVENT(加TOP 20、ORDER BY ASC、移除 1 小時時間窗、改 filter 為已完成 NOT IN ('Y','F')+ 殭屍回收)CREATE PROCEDURE TWORK_SKD_UPDATE_EVENT_STATUS- Build & publish
TsERP.SchedulerWorker到 server(建議 publish 成 self-contained 放D:\TsERP\Scheduler\) - 建專屬服務帳號(如
TsERP\schedsvc),加入本機 Administrators 群組,密碼設為「永不過期」 - 建立 Windows 工作排程:
- 觸發:每 5 分鐘
- 動作:執行
TsERP.SchedulerWorker.exe - 設定:「不要啟動新執行個體」(與 named mutex 雙保險)
- 帳號:上一步建立的服務帳號,「不論使用者是否登入皆執行」
- (可選)監控:
- Mailgun dashboard 看寄信統計
- SQL Server agent log 看 SP 觸發次數
- 工作排程器歷程記錄看 exit code(0=成功 / 1=失敗 / 2=已被 mutex 擋)
Alternatives Considered¶
- (拒絕) 純 SQL Agent +
sp_send_dbmail:通知部分可以用,但 AutoPilot 是 .NET reflection 動態跑 BLL,無法 SQL 化 - (拒絕) Worker Service 常駐:schtasks 已經滿足需求且更輕;常駐 service 還要處理 log rotation、heartbeat、graceful shutdown
- (拒絕) 混合方案(SQL Agent 跑 Notify、console 跑 AutoPilot):兩個排程通道增加運維成本,且 Notify 跟 AutoPilot 經常要看同一份
eventitem表,分兩處反而容易資料漂移 - (拒絕) 把 TimerBll 改寫但仍留在 ERP 內:解決不了「沒人開 ERP 就不跑」的根本問題