計劃在專案重用 Hangfire 跑排程之際,發現 Hangfire 對錯過排程的處理原則讓我捉摸不定,例如:若伺服器凌晨三點停機到早上八點,有個每天早上四點的排程,八點啟動時會不會補跑四點排程?在開發環境測了幾次,有時會補跑,有時不會,歸納不出規則。若無法 100% 預期行為,將重要任務交付給它讓人毛毛的,但 Hangfire 也普遍使用的 ASP.NET/ASP.NET Core 排程工具,不應存在致命 Bug,應該只是我沒搞懂規則。

於是利用假日爬文找資料,鑽原始碼查遇輯(Open Source 萬歲),還做了實驗驗證,總算解開心中迷惑,能安心使用 Hangfire。

對 Windows 工作排程器(Task Scheduler)而言,錯過的排程就是錯過了,沒有補跑這回事。但 Hangfire 每次伺服器啟動執行到 AddOrUpdate() 設定時,會重算下次執行時間,而下次執行時間有可能是過去的時間。如上圖,早上 11 點啟動伺服器時,凌晨四點排程的下次執行時間是七小時前,於是錯過排程被觸發重跑。這或許跟一般人的認知不同,但屬於設計上的決策,有好有壞,有些排程補跑是好事,但有些排程錯過就算了,在不對的時間執行反而會導致災難。

我沒在文件上找到關於重跑的定義,在網路上找到不少人反映類似問題但沒有明確結論。最後還是靠著追蹤原始碼找出邏輯,Hangfire 決定下次執行時間時會呼叫 Cronos 程式庫的 CronExpression.GetNetOccurence() 函式,依據 "0 4 * * *" Cron 時間定義、起算時間、時區決定下次觸發時間:

public DateTime? GetNextOccurrence(DateTime fromUtc, TimeZoneInfo zone, bool inclusive = false)

研究過程發現,這部分的判斷邏輯曾改版過。1.7.7 版以前的做法是「以前次執行時間推算下次執行時間,若從未執行過,則用排程建立時間推算下次執行時間」:

nextExecution = ParseCronExpression(Cron).GetNextOccurrence(
                    LastExecution ?? CreatedAt.AddSeconds(-1),
                    TimeZone,
                    inclusive: false);

1.7.8 版做了修正,除原有邏輯外再新增「當排程內容有變動時,一律以現在時間推算下次執行時間」的規則,而排程異動的定義是 Job(執行動作的程式表示式)、Cron、TimeZone、Queue 任一屬性更動。

nextExecution = ParseCronExpression(Cron).GetNextOccurrence(
                    scheduleChanged ? _now.AddSeconds(-1) : LastExecution ?? CreatedAt.AddSeconds(-1),
                    TimeZone,
                    inclusive: false);

搞懂這段,之前遇到「有時會補跑,有時不會」的現象很快有了合理解釋。以最讓我迷惑的一次測試為例,程式每次 AddOrUpdate() 將 Cron 設成五分鐘前,例如:11:00 跑設 "55 10 * * *",11:30 跑設 "25 11 * * *",排程時間永遠在五分鐘前,理論上下次執行永遠從隔天開始,但實測時前幾次沒觸發,多跑幾次就會補跑。回頭分析原因:當時我測的版本是 1.7.6 版, 第一次建立新排程由建立時間(等於當時時間)起算,之後重啟伺服器 AddOrUpdate() 時依前次執行時間或排程建立時間起算。

11:00 跑第一次測試,10:55 < 11:00(建立時間) 下次在隔天;11:03 跑第二次測試, 10:58 < 11:00,下次仍在隔天;11:06 第三次測試,這回 11:01 > 11:00,下次執行時間為當天 11:01,於 11:06 補跑排程。11:10 再跑測試,11:05 < 11:06(上次執行時間),下次在隔天;11:12 跑測試,11:07 > 11:06(上次執行時間),再次 11:12 補跑排程。當下看到的真的就是"有時會重跑,有時不會,不知道規則是什麼",在理解邏輯後,原本難以解釋的鬼打牆測試結果,瞬間合情合理條理分明,讓人有練成火眼金睛的爽感,千金不換。

為了確認,我特別寫了一段測試,由於這段邏輯用到不少 private、internal 成員,我也順便溫習了 Refletion 技巧:(範例程式已上傳到 Github)

using System.Reflection;
using Hangfire;

Func<DateTime, long> toTimestamp = (d) => {
    TimeSpan ts = d - new DateTime(1970, 1, 1);
    return Convert.ToInt64(ts.TotalSeconds);
};
Func<long, DateTime> toDateTime = (l) => {
    return new DateTime(1970, 1, 1).AddSeconds(l);
};
var tzResolver = new DefaultTimeZoneResolver();
var recurJob = new Dictionary<string, string>
{
    ["Queue"] = "default",
    ["Cron"] = "0 0 * * *", //to assign
    ["TimeZoneId"] = "Taipei Standard Time",
    ["Job"] = "{}",
    ["CreatedAt"] = "", //to assign
    ["NextExecution"] = "", 
    ["LastExecution"] = "", //to assign
    ["LastJobId"] = ""
};
var jobEntType = typeof(RecurringJob).Assembly.GetType("Hangfire.RecurringJobEntity");
var jobEntConstructor = jobEntType.GetConstructor(new[] {
        typeof(string), typeof(IDictionary<string, string>),
        typeof(ITimeZoneResolver), typeof(DateTime) });
//ver 1.7.31
//bool TryGetNextExection(bool scheduledChanged, out DateTime? nextExecution, out Exception error)
var trypGetNextExecution =
    jobEntType.GetMethod("TryGetNextExecution", BindingFlags.Instance | BindingFlags.NonPublic);

Action<bool, string, DateTime, DateTime?> test = (changed, cron, createdAt, lastExecution) => {
    Console.Write($"{(changed ? "Changed" : "Unchange")} / {cron} / {createdAt:HH:mm:ss} / {(lastExecution.HasValue ? lastExecution.Value.ToString("HH:mm:ss") : string.Empty)} => ");
    recurJob["Cron"] = cron;
    recurJob["CreatedAt"] = toTimestamp(createdAt.ToUniversalTime()).ToString();
    recurJob["LastExecution"] = lastExecution.HasValue ? toTimestamp(lastExecution.Value.ToUniversalTime()).ToString() : string.Empty;
    var jobEnt = jobEntConstructor.Invoke(new object[]
    {
        "Test", recurJob, tzResolver, DateTime.UtcNow
    });
    var param = new object[] { changed, null, null };
    trypGetNextExecution.Invoke(jobEnt, param);
    Console.WriteLine(((DateTime)param[1]).ToString());
};

var trigTime = DateTime.Now.AddMinutes(-1);
var cron = $"{trigTime.Minute} {trigTime.Hour} * * *";
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"Current Time={DateTime.Now:HH:mm:ss}");
Console.ResetColor();
//1.7.31
//RecurringJobEntity.ParseCronExpression(this.Cron)
//.GetNextOccurrence(scheduleChanged ? this._now.AddSeconds(-1.0) : (this.LastExecution ?? this.CreatedAt.AddSeconds(-1.0)), this.TimeZone, false);
//Changed -> Job、Cron、TimeZone、Queue different

//若排程有修改,下次執行時間由現在時間推算,若排程有修改由前次執行時間推算下次執行時間,若無前次執行時間,以建立時間推算
test(false, cron, trigTime.AddMinutes(1), null); //次日執行
test(false, cron, trigTime, null); //次日執行
test(false, cron, trigTime.AddMinutes(-1), null); //當日執行

//有前次執行時間時,以前次時間推算
test(false, cron, trigTime.AddMinutes(-1), trigTime.AddMinutes(1)); //次日執行
test(false, cron, trigTime.AddMinutes(1), trigTime.AddMinutes(1)); //次日執行
test(false, cron, trigTime, trigTime); //次日執行
test(false, cron, trigTime, trigTime.AddMinutes(-1)); //當日執行
test(false, cron, trigTime.AddMinutes(1), trigTime.AddMinutes(-1)); //當日執行

//若排程異動,以現在時間推算
test(true, cron, trigTime.AddMinutes(-1), trigTime.AddMinutes(1)); //次日執行
test(true, cron, trigTime.AddMinutes(1), trigTime.AddMinutes(1)); //次日執行
test(true, cron, trigTime, trigTime); //次日執行
test(true, cron, trigTime, trigTime.AddMinutes(-1)); //次日執行
test(true, cron, trigTime.AddMinutes(1), trigTime.AddMinutes(-1)); //次日執行

測試結果全部符合預期:

確認 Hangfire 行為後,整理結論如下:

  1. 若 Hangfire 伺服器維持 24H 運作僅偶爾重開機,基本上不需煩惱重跑排程問題。
  2. 若 Hangfire 伺服器歷經長時間中斷,預設會重跑停機期間錯過的排程。以前次執時間推算,立即重跑,但如停機三天錯過三次,僅會重跑一次。
  3. 若排程因特性,錯過時段不宜重跑,解法有二:
    每次刪除重建 參考
    RecurringJob.RemoveIfExists(Id);
    RecurringJob.AddOrUpdate(Id, () => RunJob(), cron, timezone);
    
    Job 方法加入無關緊要的參數,每次 AddOrUpdate() 時入不同值(跟 URL 加上 ?_= Math.random() 防止 Cache 有異曲同工之妙) [註:並需升級至 Hangfire v1.7.8 以後版本]
    RecurringJob.AddOrUpdate(Id, () => RunJob(DateTime.Now.Tick), cron, timezone);
    

This article reveal how Hangfire decide if the missed recurring jobs need to be triggered or not on startup?


Comments

Be the first to post a comment

Post a comment