دو راهی که زمان‌بند تسک تکرارشونده خراب می‌شود

«هر دوشنبه ساعت ۹ صبح، تسکی به‌نام "آماده‌سازی گزارش هفتگی" بساز.» ریاضی تاریخ برای این واقعاً ساده است. چیزی که ساده نیست این است که وقتی این زمان‌بند را روی بیش از یک سرور اجرا کنید چه اتفاقی می‌افتد، و وقتی یک قاعده‌ی تکرارِ خاص به‌نوعی خراب باشد چه می‌شود — هر دو موردی که یک قابلیت ساده را یا به انباشتن تسک‌های تکراری هر هفته تبدیل می‌کنند یا به یک کار پس‌زمینه که برای همیشه روی همان شکست تلاش‌مجدد می‌زند.

حالت شکست اول: دو نمونه، یک دوشنبه

اگر هر نمونه‌ی بک‌اند تیکر خودش را اجرا کند که چک می‌کند «چیزی سررسیده؟»، هر نمونه همان تکرار سررسیده را در همان لحظه پیدا می‌کند و هر نمونه یک تسک برایش می‌سازد. این همان شکل مسئله‌ی ایمیل خلاصه‌ی هفتگی است — به «چرا خلاصه‌ی هفتگی هرگز دوبار ارسال نمی‌شود» نگاه کنید — اما WKFGo اینجا آن را متفاوت حل می‌کند، چون ساخت تسک یک درج ایمپوتنسیِ تک‌مرحله‌ای مثل ردیف یکتای (org, week) نیست.

راه‌حل انتخاب رهبر است به‌جای یک محدودیت پایگاه‌داده. WKFGo با یک قفل مشاوره‌ای سطح‌نشست پستگرس یک نمونه را به‌عنوان رهبر انتخاب می‌کند، و تیکر تکرار قبل از هر کاری آن وضعیت را چک می‌کند:

for range ticker.C {
    // Singleton job: only the elected leader materializes recurrences so
    // a task is not created once per running replica.
    if !cluster.IsLeader() {
        continue
    }
    s.RunDue()
}

هر نمونه تیکر را اجرا می‌کند؛ فقط آن که قفل مشاوره‌ای را نگه دارد واقعاً عمل می‌کند. اگر پردازه‌ی رهبر بمیرد، نشست پایگاه‌داده‌اش می‌افتد، پستگرس قفل را آزاد می‌کند، و نمونه‌ی دیگری در تیکر بعدی‌اش آن را می‌گیرد — شکست‌گذاری بدون هیچ زیرساخت هماهنگی اضافه، و روی استقرار تک‌نمونه‌ای قفل فوراً گرفته می‌شود پس چیزی متفاوت رفتار نمی‌کند.

حالت شکست دوم: قاعده‌ای خراب که حلقه‌ی داغ می‌زند

فرض کنید یک تکرار به ستونی اشاره می‌کند که حذف شده، و Materialize هر بار که زمان‌بند سعی‌اش می‌کند شروع به شکست‌خوردن می‌کند. پیاده‌سازی ساده‌لوحانه همان قاعده را هر تیک دوباره تلاش می‌کند، برای همیشه، هر دقیقه یک خطا لاگ می‌کند و هرگز پیشرفتی نمی‌کند. زمان‌بند WKFGo next_run_at را صرف‌نظر از موفقیت یا شکست ساخته‌شدن پیش می‌برد:

if err := s.Materialize(&due[i]); err != nil {
    log.Printf("[recurrence] rule %d (%q) failed: %v", due[i].ID, due[i].Title, err)
    // Push next_run_at forward anyway so a broken rule can't hot-loop.
}
now := time.Now()
s.db.Model(&Recurrence{}).Where("id = ?", due[i].ID).Updates(map[string]interface{}{
    "next_run_at": due[i].ComputeNext(now),
    ...
})

یک قاعده‌ی خراب دقیقاً یک تلاش به‌ازای هر رخداد زمان‌بندی‌شده می‌گیرد، نه یک طوفان تلاش‌مجدد بی‌کران — شکست می‌خورد، لاگ می‌شود، و برنامه به رخداد بعدی می‌رود به‌جای اینکه فوراً همان رخدادی که خراب شده را دوباره امتحان کند. بده‌بستان صریح است: یک قاعده‌ی خراب یک تسک را بی‌صدا رد می‌کند به‌جای اینکه کل پاسِ زمان‌بند را پشت یک حلقه‌ی تلاش‌مجدد مسدود کند. با توجه به اینکه جایگزین یک زمان‌بند است که می‌تواند صفر پیشرفت روی هر تکرار دیگری کند چون یکی خراب است، پیش‌رفتن انتخاب امن‌تری است.

حالت مرزیِ پایان ماه

ComputeNext برای یک تکرار ماهانه روز ماه را روی ۲۸ محدود می‌کند:

day := r.MonthDay
if day < 1 {
    day = 1
}
if day > 28 {
    day = 28 // keep it valid in February too
}

یک قاعده تنظیم‌شده برای «سی‌ویکم هر ماه» یا در فوریه خطا می‌داد یا بسته به رفتار سرریز کتابخانه‌ی تقویم بی‌صدا جابه‌جا می‌شد — محدودکردن به ۲۸ یک مقدار کمِ دقت تاریخ را (همیشه بیست‌وهشتم، هیچ‌وقت سی یا سی‌ویک) با برنامه‌ای که در هر ماه یکسان رفتار می‌کند، بدون هیچ حالت ویژه‌ای برای فوریه یا ماه‌های سی‌روزه، معاوضه می‌کند.

چیزی که «ساخته‌شدن» واقعاً می‌سازد

ساختن تسک فقط یک INSERT نیست — کاربران پیکربندی‌شده را هم تخصیص می‌دهد، به هرکدام اعلان درون‌برنامه‌ای می‌فرستد، و همان رویداد بلادرنگ task.created را که یک تسک ساخته‌شده‌ی دستی می‌گرفت پخش می‌کند، پس یک تسک تکرارشونده دقیقاً مثل یکی که یک انسان همین الان تایپ کرده روی بورد زنده ظاهر می‌شود:

realtime.Broadcast(r.ProjectID, "task.created", 0, map[string]interface{}{
    "id": task.ID, "recurrenceId": r.ID,
})

هیچ‌چیز پایین‌دست — بورد، وب‌هوک‌ها، اعلان‌ها — لازم نیست بداند تسک از یک تکرار آمده یا از یک انسان؛ به هر حال همان رویداد است، با recurrenceId برچسب‌گذاری‌شده برای هرکسی که بخواهد رویش فیلتر بزند.

اشتباهات رایج

تلاش‌مجددِ بی‌پایان یک کار زمان‌بندی‌شده‌ی شکست‌خورده به‌جای پیش‌بردن از آن. امن‌تر به‌نظر می‌رسد («دفعه‌ی بعد می‌گیریمش») اما یک قاعده‌ی واقعاً خراب بعد چرخه‌های زمان‌بند را برای همیشه مصرف می‌کند به‌جای یک‌بار شکست‌خوردن و در لاگ‌ها ظاهرشدن برای اینکه کسی درستش کند.

اجرای کارهای پس‌زمینه‌ی تک‌نمونه‌ای روی هر نمونه بدون هماهنگی. روی یک نمونه خوب کار می‌کند و همان لحظه‌ای که به‌صورت افقی مقیاس‌گیری می‌کنید بی‌صدا شروع به تکراری‌کردن خروجی می‌کند — دقیقاً همان نوع باگی که فقط در محیط تولید، زیر بار، درست وقتی کمترین انتظارش را دارید نمایان می‌شود.

محدودنکردن حالات مرزی تقویم (سی‌ام فوریه، طول ماه‌ها). کد تسک تکرارشونده و صورتحساب تکرارشونده هر دو با این برخورد می‌کنند؛ محاسبات محدودنشده‌ی روز-ماه یا خطا می‌دهد یا بسته به کتابخانه‌ی تاریخ زبان جابه‌جا می‌شود.

سؤالات متداول

می‌توانم یک تکرار را دستی، خارج از برنامه‌اش اجرا کنم؟

بله — یک نقطه‌پایانی «همین الان اجرا کن» وجود دارد که مستقیماً Materialize را صدا می‌زند، مفید برای تست یک قاعده‌ی جدید بدون منتظرماندن برای رخداد زمان‌بندی‌شده‌ی بعدی‌اش.

اگر قاعده‌ی تکرار را حذف کنم چه اتفاقی برای تسک‌های از‌قبل‌ساخته‌شده می‌افتد؟

دست‌نخورده می‌مانند — یک تکرار فقط تسک‌های آینده می‌سازد؛ حذف قاعده ساخت آینده را متوقف می‌کند و هیچ اثر گذشته‌نگری روی تسک‌های قبلی ندارد.

آیا یک تکرارِ متوقف‌شده (غیرفعال) همچنان هر تیک چک می‌شود؟

کوئری سررسیده روی active = true در سطح پایگاه‌داده فیلتر می‌کند، پس یک تکرار غیرفعال هیچ‌وقت حتی واکشی نمی‌شود، چه برسد به ساخته‌شدن.

جمع‌بندی

یک قابلیت تسک تکرارشونده واقعاً درباره‌ی ریاضی تقویم نیست — آن بخش یک روز کار است. درباره‌ی این است که مطمئن شوید چیزی که ریاضی تقویم را اجرا می‌کند دقیقاً یک‌بار به‌ازای هر رخداد اجرا می‌شود، در سراسر خوشه، و یک قاعده‌ی خراب نمی‌تواند بقیه‌ی کار زمان‌بندی‌شده‌تان را با خودش پایین بکشد.