fix: rotate daily counters by calendar date across DST (#5555)

* Fix daily counter rotation across daylight-saving changes

* Test daily counter rotation across DST and skipped midnight
This commit is contained in:
dhairya
2026-10-09 20:13:03 +08:00
committed by GitHub
parent 1dbd44edd3
commit ab0959868a
2 changed files with 70 additions and 9 deletions
+6 -5
View File
@@ -58,7 +58,7 @@ func newStandardDateCounterWithClock(reserveDays int64, clk clock.PassiveClock)
reserveDays: reserveDays, reserveDays: reserveDays,
counts: make([]int64, reserveDays), counts: make([]int64, reserveDays),
clock: clk, clock: clk,
lastUpdateDate: startOfDay(clk.Now()), lastUpdateDate: calendarDate(clk.Now()),
} }
} }
@@ -114,7 +114,7 @@ func (c *StandardDateCounter) Clear() {
// rotate // rotate
// Must hold the lock before calling this function. // Must hold the lock before calling this function.
func (c *StandardDateCounter) rotate(now time.Time) { func (c *StandardDateCounter) rotate(now time.Time) {
now = startOfDay(now) now = calendarDate(now)
days := int(now.Sub(c.lastUpdateDate).Hours() / 24) days := int(now.Sub(c.lastUpdateDate).Hours() / 24)
reserveDays := int(c.reserveDays) reserveDays := int(c.reserveDays)
@@ -132,7 +132,8 @@ func (c *StandardDateCounter) rotate(now time.Time) {
c.lastUpdateDate = now c.lastUpdateDate = now
} }
// startOfDay returns midnight in t's location. // calendarDate represents t's local date as midnight UTC for day arithmetic.
func startOfDay(t time.Time) time.Time { // This avoids variable day lengths and nonexistent local midnights during DST.
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) func calendarDate(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
} }
+64 -4
View File
@@ -31,7 +31,7 @@ func TestDateCounter(t *testing.T) {
func TestDateCounterRotate(t *testing.T) { func TestDateCounterRotate(t *testing.T) {
loc := time.FixedZone("test", 8*60*60) loc := time.FixedZone("test", 8*60*60)
lastUpdateDate := time.Date(2026, time.May, 8, 0, 0, 0, 0, loc) lastUpdateDate := time.Date(2026, time.May, 8, 0, 0, 0, 0, time.UTC)
tests := []struct { tests := []struct {
name string name string
@@ -55,19 +55,19 @@ func TestDateCounterRotate(t *testing.T) {
name: "one day", name: "one day",
now: time.Date(2026, time.May, 9, 12, 30, 0, 0, loc), now: time.Date(2026, time.May, 9, 12, 30, 0, 0, loc),
want: []int64{0, 10, 7}, want: []int64{0, 10, 7},
wantLastUpdateDate: time.Date(2026, time.May, 9, 0, 0, 0, 0, loc), wantLastUpdateDate: time.Date(2026, time.May, 9, 0, 0, 0, 0, time.UTC),
}, },
{ {
name: "two days", name: "two days",
now: time.Date(2026, time.May, 10, 12, 30, 0, 0, loc), now: time.Date(2026, time.May, 10, 12, 30, 0, 0, loc),
want: []int64{0, 0, 10}, want: []int64{0, 0, 10},
wantLastUpdateDate: time.Date(2026, time.May, 10, 0, 0, 0, 0, loc), wantLastUpdateDate: time.Date(2026, time.May, 10, 0, 0, 0, 0, time.UTC),
}, },
{ {
name: "all reserved days elapsed", name: "all reserved days elapsed",
now: time.Date(2026, time.May, 11, 12, 30, 0, 0, loc), now: time.Date(2026, time.May, 11, 12, 30, 0, 0, loc),
want: []int64{0, 0, 0}, want: []int64{0, 0, 0},
wantLastUpdateDate: time.Date(2026, time.May, 11, 0, 0, 0, 0, loc), wantLastUpdateDate: time.Date(2026, time.May, 11, 0, 0, 0, 0, time.UTC),
}, },
} }
@@ -132,3 +132,63 @@ func TestDateCounterConcurrentAccess(t *testing.T) {
} }
wg.Wait() wg.Wait()
} }
func TestDateCounterCalendarDaysAcrossDST(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
require.NoError(t, err)
midnightLoc, err := time.LoadLocation("America/Sao_Paulo")
require.NoError(t, err)
tests := []struct {
name string
start time.Time
end time.Time
want []int64
}{
{
name: "spring next day",
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
end: time.Date(2026, time.March, 9, 12, 0, 0, 0, loc),
want: []int64{0, 10, 0},
},
{
name: "spring two days",
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
end: time.Date(2026, time.March, 10, 12, 0, 0, 0, loc),
want: []int64{0, 0, 10},
},
{
name: "spring retention expiry",
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
end: time.Date(2026, time.March, 11, 12, 0, 0, 0, loc),
want: []int64{0, 0, 0},
},
{
name: "fall next day",
start: time.Date(2026, time.November, 1, 12, 0, 0, 0, loc),
end: time.Date(2026, time.November, 2, 12, 0, 0, 0, loc),
want: []int64{0, 10, 0},
},
{
name: "skipped midnight",
start: time.Date(2018, time.November, 3, 12, 0, 0, 0, midnightLoc),
end: time.Date(2018, time.November, 4, 12, 0, 0, 0, midnightLoc),
want: []int64{0, 10, 0},
},
{
name: "day after skipped midnight",
start: time.Date(2018, time.November, 4, 12, 0, 0, 0, midnightLoc),
end: time.Date(2018, time.November, 5, 12, 0, 0, 0, midnightLoc),
want: []int64{0, 10, 0},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clk := clocktesting.NewFakeClock(tt.start)
dc := newStandardDateCounterWithClock(3, clk)
dc.Inc(10)
clk.SetTime(tt.end)
require.Equal(t, tt.want[0], dc.TodayCount())
require.Equal(t, tt.want, dc.GetLastDaysCount(3))
})
}
}