跳轉到

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 分鐘觸發一次:

  1. 行事曆事件通知(Mailgun 寄信、SMS)
  2. 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=trueOutputType=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.csproj
  • Program.csSchedulerJob.csSchedulerSettings.csSchedulerUser.cs
  • appsettings.json
  • LogicBll/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

  1. DBA 端:對每個 darb DB 跑
  2. ALTER TABLE twork_skd_eventitem ADD 執行開始時間 datetime NULL
  3. ALTER PROCEDURE TWORK_SKD_NOTIFYEVENT(加 TOP 20ORDER BY ASC、移除 1 小時時間窗、改 filter 為 已完成 NOT IN ('Y','F') + 殭屍回收)
  4. CREATE PROCEDURE TWORK_SKD_UPDATE_EVENT_STATUS
  5. Build & publish TsERP.SchedulerWorker 到 server(建議 publish 成 self-contained 放 D:\TsERP\Scheduler\
  6. 建專屬服務帳號(如 TsERP\schedsvc),加入本機 Administrators 群組,密碼設為「永不過期」
  7. 建立 Windows 工作排程
  8. 觸發:每 5 分鐘
  9. 動作:執行 TsERP.SchedulerWorker.exe
  10. 設定:「不要啟動新執行個體」(與 named mutex 雙保險)
  11. 帳號:上一步建立的服務帳號,「不論使用者是否登入皆執行」
  12. (可選)監控
  13. Mailgun dashboard 看寄信統計
  14. SQL Server agent log 看 SP 觸發次數
  15. 工作排程器歷程記錄看 exit code(0=成功 / 1=失敗 / 2=已被 mutex 擋)

Alternatives Considered

  1. (拒絕) 純 SQL Agent + sp_send_dbmail:通知部分可以用,但 AutoPilot 是 .NET reflection 動態跑 BLL,無法 SQL 化
  2. (拒絕) Worker Service 常駐:schtasks 已經滿足需求且更輕;常駐 service 還要處理 log rotation、heartbeat、graceful shutdown
  3. (拒絕) 混合方案(SQL Agent 跑 Notify、console 跑 AutoPilot):兩個排程通道增加運維成本,且 Notify 跟 AutoPilot 經常要看同一份 eventitem 表,分兩處反而容易資料漂移
  4. (拒絕) 把 TimerBll 改寫但仍留在 ERP 內:解決不了「沒人開 ERP 就不跑」的根本問題