From b05bbab3cdf05dc9ccb7f8e4e23faa57372256f2 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 1 Jul 2026 09:31:58 +0700 Subject: [PATCH] fix(cron): claim daily pushes by ict day --- internal/modules/lolschedule/cron.go | 33 +++++++++---------- internal/modules/lolschedule/cron_test.go | 39 ++++++++++++++++++++--- internal/modules/wc/cron.go | 12 +++---- internal/modules/wc/cron_test.go | 29 +++++++++++++++++ 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/internal/modules/lolschedule/cron.go b/internal/modules/lolschedule/cron.go index f628604..c7e1773 100644 --- a/internal/modules/lolschedule/cron.go +++ b/internal/modules/lolschedule/cron.go @@ -85,10 +85,10 @@ const dailyPushCronName = "lolschedule_daily_push" // expression is UTC; 01:00 UTC == 08:00 ICT. const dailyPushSchedule = "0 1 * * *" -// lastPushDateKey records the UTC date (YYYY-MM-DD) of the most recent +// lastPushDateKey records the ICT date (YYYY-MM-DD) of the most recent // completed daily push. The handler claims this key before fanning out and -// no-ops if it is already today's date, making the push idempotent per UTC -// date. This defends against double-fire windows from rolling deploys that +// no-ops if it is already today's schedule day, making the push idempotent per +// ICT date. This defends against double-fire windows from rolling deploys that // briefly run two containers or operator misconfiguration. const lastPushDateKey = "daily_push:last_date" @@ -135,18 +135,19 @@ func (s *state) dailyPushHandler(ctx context.Context, deps modules.Deps) error { return runDailyPush(ctx, s, deps.Bot) } -// claimDailyPush atomically records that today's UTC push is happening and -// reports whether THIS caller won the claim. It is the idempotency primitive -// for the daily push: a winner proceeds to fan out; a loser (another trigger -// already claimed today) returns false and sends nothing. +// claimDailyPush atomically records that today's ICT schedule-day push is +// happening and reports whether THIS caller won the claim. It is the +// idempotency primitive for the daily push: a winner proceeds to fan out; a +// loser (another trigger already claimed today) returns false and sends +// nothing. // // The claim uses version-based optimistic write (PutVersioned) on // lastPushDateKey so two simultaneous triggers cannot both win. -func claimDailyPush(ctx context.Context, store PushDateStore, today string) (bool, error) { +func claimDailyPush(ctx context.Context, store PushDateStore, pushDay string) (bool, error) { current, version, err := store.Get(ctx, lastPushDateKey) switch { case err == nil: - if current.Date == today { + if current.Date == pushDay { return false, nil // already pushed today } case errors.Is(err, storage.ErrNotFound): @@ -155,7 +156,7 @@ func claimDailyPush(ctx context.Context, store PushDateStore, today string) (boo return false, err } - if err := store.PutVersioned(ctx, lastPushDateKey, version, lastPushDoc{Date: today}); err != nil { + if err := store.PutVersioned(ctx, lastPushDateKey, version, lastPushDoc{Date: pushDay}); err != nil { if errors.Is(err, storage.ErrConflict) { return false, nil // another trigger claimed today first } @@ -191,16 +192,16 @@ func runDailyPush(ctx context.Context, s *state, sender messageSender) error { disableNotification := len(filtered) == 0 // Idempotency gate: claim today's push before sending. A lost claim means - // another trigger already pushed (or is pushing) for this UTC date, so we - // send nothing. Placed after the fetch so a transient fetch failure does - // not consume the day's claim. - today := s.now().UTC().Format("2006-01-02") - won, err := claimDailyPush(ctx, s.pushDate, today) + // another trigger already pushed (or is pushing) for this ICT schedule day, + // so we send nothing. Placed after the fetch so a transient fetch failure + // does not consume the day's claim. + pushDay := ictDayKey(from) + won, err := claimDailyPush(ctx, s.pushDate, pushDay) if err != nil { return fmt.Errorf("lolschedule daily push: claim date: %w", err) } if !won { - log.Info("lolschedule daily push: already pushed today, skipping", "date", today) + log.Info("lolschedule daily push: already pushed today, skipping", "date", pushDay) return nil } diff --git a/internal/modules/lolschedule/cron_test.go b/internal/modules/lolschedule/cron_test.go index 0975648..39b5207 100644 --- a/internal/modules/lolschedule/cron_test.go +++ b/internal/modules/lolschedule/cron_test.go @@ -210,11 +210,11 @@ func TestRunDailyPush_ForwardsMessageThreadID(t *testing.T) { } } -// TestRunDailyPush_IdempotentPerDate locks in the double-fire guard: invoking -// the handler twice on the same UTC date sends each subscriber exactly one -// digest. Defends against rolling-deploy overlap and operator +// TestRunDailyPush_IdempotentPerICTDay locks in the double-fire guard: invoking +// the handler twice on the same ICT schedule day sends each subscriber exactly +// one digest. Defends against rolling-deploy overlap and operator // misconfiguration (all double-fire windows the daily push must survive). -func TestRunDailyPush_IdempotentPerDate(t *testing.T) { +func TestRunDailyPush_IdempotentPerICTDay(t *testing.T) { s := newTestState(t) seedFreshCache(t, s.cache, nil) @@ -232,11 +232,40 @@ func TestRunDailyPush_IdempotentPerDate(t *testing.T) { } } if len(sender.calls) != len(chatIDs) { - t.Errorf("two same-date pushes sent %d messages, want %d (one per subscriber)", + t.Errorf("two same-ICT-day pushes sent %d messages, want %d (one per subscriber)", len(sender.calls), len(chatIDs)) } } +func TestRunDailyPush_ClaimsICTDayAtMidnight(t *testing.T) { + s := newTestState(t) + s.nowFn = func() time.Time { + return time.Date(2026, 5, 9, 17, 0, 0, 0, time.UTC) // 2026-05-10 00:00 ICT + } + seedFreshCache(t, s.cache, nil) + if _, err := addSubscriber(context.Background(), s.subscribers, 100, 0); err != nil { + t.Fatal(err) + } + if err := s.pushDate.Put(context.Background(), lastPushDateKey, lastPushDoc{Date: "2026-05-09"}); err != nil { + t.Fatal(err) + } + + sender := &fakeSender{} + if err := runDailyPush(context.Background(), s, sender); err != nil { + t.Fatal(err) + } + if len(sender.calls) != 1 { + t.Fatalf("calls = %d, want 1 midnight ICT push", len(sender.calls)) + } + doc, _, err := s.pushDate.Get(context.Background(), lastPushDateKey) + if err != nil { + t.Fatal(err) + } + if doc.Date != "2026-05-10" { + t.Fatalf("last push date = %q, want ICT day 2026-05-10", doc.Date) + } +} + func TestRunDailyPush_PartialFailureContinues(t *testing.T) { s := newTestState(t) seedFreshCache(t, s.cache, nil) diff --git a/internal/modules/wc/cron.go b/internal/modules/wc/cron.go index 1d24de5..f3aea30 100644 --- a/internal/modules/wc/cron.go +++ b/internal/modules/wc/cron.go @@ -51,11 +51,11 @@ func (s *state) dailyPushHandler(ctx context.Context, deps modules.Deps) error { return runDailyPush(ctx, s, deps.Bot) } -func claimDailyPush(ctx context.Context, store PushDateStore, today string) (bool, error) { +func claimDailyPush(ctx context.Context, store PushDateStore, pushDay string) (bool, error) { current, version, err := store.Get(ctx, lastPushDateKey) switch { case err == nil: - if current.Date == today { + if current.Date == pushDay { return false, nil } case errors.Is(err, storage.ErrNotFound): @@ -64,7 +64,7 @@ func claimDailyPush(ctx context.Context, store PushDateStore, today string) (boo return false, err } - if err := store.PutVersioned(ctx, lastPushDateKey, version, lastPushDoc{Date: today}); err != nil { + if err := store.PutVersioned(ctx, lastPushDateKey, version, lastPushDoc{Date: pushDay}); err != nil { if errors.Is(err, storage.ErrConflict) { return false, nil } @@ -91,13 +91,13 @@ func runDailyPush(ctx context.Context, s *state, sender messageSender) error { } text := RenderToday(matches, from) - today := s.now().UTC().Format("2006-01-02") - won, err := claimDailyPush(ctx, s.pushDate, today) + pushDay := ictDayKey(from) + won, err := claimDailyPush(ctx, s.pushDate, pushDay) if err != nil { return fmt.Errorf("wc daily push: claim date: %w", err) } if !won { - log.Info("wc daily push: already pushed today, skipping", "date", today) + log.Info("wc daily push: already pushed today, skipping", "date", pushDay) return nil } diff --git a/internal/modules/wc/cron_test.go b/internal/modules/wc/cron_test.go index a9bfd2e..6729de5 100644 --- a/internal/modules/wc/cron_test.go +++ b/internal/modules/wc/cron_test.go @@ -86,6 +86,35 @@ func TestRunDailyPush_SendsAndIsIdempotent(t *testing.T) { } } +func TestRunDailyPush_ClaimsICTDayAtMidnight(t *testing.T) { + s := newTestState() + s.nowFn = func() time.Time { + return time.Date(2026, 6, 12, 17, 0, 0, 0, time.UTC) // 2026-06-13 00:00 ICT + } + seedFreshCache(t, s.cache, []Match{mkMatch("TIMED", "MEX", "RSA", "2026-06-12T18:00:00Z")}) + if _, err := addSubscriber(context.Background(), s.subscribers, 100, 0); err != nil { + t.Fatal(err) + } + if err := s.pushDate.Put(context.Background(), lastPushDateKey, lastPushDoc{Date: "2026-06-12"}); err != nil { + t.Fatal(err) + } + + sender := &fakeSender{} + if err := runDailyPush(context.Background(), s, sender); err != nil { + t.Fatal(err) + } + if len(sender.calls) != 1 { + t.Fatalf("calls = %d, want 1 midnight ICT push", len(sender.calls)) + } + doc, _, err := s.pushDate.Get(context.Background(), lastPushDateKey) + if err != nil { + t.Fatal(err) + } + if doc.Date != "2026-06-13" { + t.Fatalf("last push date = %q, want ICT day 2026-06-13", doc.Date) + } +} + func TestRunDailyPush_PrunesDeadChat(t *testing.T) { s := newTestState() seedFreshCache(t, s.cache, nil)