fix(cron): claim daily pushes by ict day

This commit is contained in:
2026-07-01 09:31:58 +07:00
parent 03445e18f9
commit b05bbab3cd
4 changed files with 86 additions and 27 deletions
+17 -16
View File
@@ -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
}
+34 -5
View File
@@ -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)
+6 -6
View File
@@ -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
}
+29
View File
@@ -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)