mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-10 00:22:10 +00:00
fix(cron): claim daily pushes by ict day
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user