mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
timer: add scheduler/timer with day precision
timer.go has a timer struct for scheduling things to happen once every n days before or after a given time. Pass a string list of day deltas to parse, a unit to parse these as (only 24/-24 hours really make sense), then call Check() on the returned struct with your "since" time, and the time a timer was last fired. If one goes off, store the time so you can pass it in subsequent calls. To be used in the user daemon for "remind every N days" functionality. Was initially gonna allow more precision than days, but ran into problems, most likely from me overcomplicating it and not wanting to store too much data. some tests also in timer_test.go.
This commit is contained in:
92
timer.go
Normal file
92
timer.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// The maximum duration distance from a trigger time that it will be triggered. If multiple trigger times are provided closer than this value, the smallest will be used instead.
|
||||||
|
MAX_MIN_INTERVAL = 18 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
type Clock interface {
|
||||||
|
Now() time.Time
|
||||||
|
Since(t time.Time) time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type realClock struct{}
|
||||||
|
|
||||||
|
func (realClock) Now() time.Time { return time.Now() }
|
||||||
|
func (realClock) Since(t time.Time) time.Duration { return time.Since(t) }
|
||||||
|
|
||||||
|
// DayTimerSet holds information required to trigger timers. Can be generated with NewTimerSet. Does not have it's own event loop, one should check as regularly as they like whether and which of the timers should go off with Check(), passing the last known time one fired (you should track this yourself, the DayTimerSet struct isn't something that needs to be stored).
|
||||||
|
type DayTimerSet struct {
|
||||||
|
deltas []time.Duration
|
||||||
|
clock Clock
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDayTimerSet(deltaStrings []string, unit time.Duration) DayTimerSet {
|
||||||
|
as := DayTimerSet{
|
||||||
|
deltas: make([]time.Duration, 0, len(deltaStrings)),
|
||||||
|
clock: realClock{},
|
||||||
|
}
|
||||||
|
for i := range deltaStrings {
|
||||||
|
// d, err := strconv.ParseFloat(deltaStrings[i], 64)
|
||||||
|
d, err := strconv.ParseInt(deltaStrings[i], 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
as.deltas = append(as.deltas, time.Duration(d)*unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return as
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns one or no time.Duration values, Giving the delta for the timer which went off. Pass a non-zero lastFired to stop too many going off at once, and store the returned time.Time value to pass as this later.
|
||||||
|
func (as DayTimerSet) Check(since time.Time, lastFired time.Time) time.Duration {
|
||||||
|
// Keep track of the timer that's most recently gone off, so we don't for example send a "your account expires in 3 days" 1 day away from expiry if the server's been turned off for a while.
|
||||||
|
soonestTimerDesiredDelta := time.Duration(0)
|
||||||
|
soonestTimerRealDelta := 1e5 * time.Hour
|
||||||
|
for _, dt := range as.deltas {
|
||||||
|
if dt == time.Duration(0) {
|
||||||
|
// fmt.Printf("not firing: zero delta\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
now := as.clock.Now()
|
||||||
|
y1, m1, d1 := now.Date()
|
||||||
|
|
||||||
|
if !lastFired.IsZero() {
|
||||||
|
y2, m2, d2 := lastFired.Date()
|
||||||
|
if y2 == y1 && m2 == m1 && d2 == d1 {
|
||||||
|
// fmt.Printf("not firing: same day as last fire (%d.%d.%d == %d.%d.%d)\n", y2, m2, d2, y1, m1, d1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if as.clock.Since(lastFired) < MAX_MIN_INTERVAL {
|
||||||
|
// fmt.Printf("not firing: not enough time since last fire (%v < %v)\n", as.clock.Since(lastFired), MAX_MIN_INTERVAL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nd := since.Add(dt)
|
||||||
|
|
||||||
|
y2, m2, d2 := nd.Date()
|
||||||
|
if y2 != y1 || m2 != m1 || d2 != d1 {
|
||||||
|
// fmt.Printf("not firing: not same day (%d.%d.%d != %d.%d.%d)\n", y2, m2, d2, y1, m1, d1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dNowNotif := now.Sub(nd).Abs()
|
||||||
|
|
||||||
|
if dNowNotif > MAX_MIN_INTERVAL {
|
||||||
|
// fmt.Printf("not firing: not close enough to fire time (%v > %v)\n", dNowNotif, MAX_MIN_INTERVAL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if dNowNotif < soonestTimerRealDelta {
|
||||||
|
soonestTimerDesiredDelta = dt
|
||||||
|
soonestTimerRealDelta = dNowNotif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return soonestTimerDesiredDelta
|
||||||
|
}
|
||||||
138
timer_test.go
Normal file
138
timer_test.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeClock struct {
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeClock) Now() time.Time { return f.now }
|
||||||
|
func (f fakeClock) Since(t time.Time) time.Duration { return f.now.Sub(t) }
|
||||||
|
|
||||||
|
// Tests the timer with negative time deltas, i.e. reminders before an event.
|
||||||
|
func TestTimerNegative(t *testing.T) {
|
||||||
|
as := NewDayTimerSet([]string{
|
||||||
|
"1", "2", "3", "7",
|
||||||
|
}, -24*time.Hour)
|
||||||
|
|
||||||
|
since := time.Date(2025, 8, 9, 1, 0, 0, 0, time.UTC)
|
||||||
|
nowTimes := []time.Time{
|
||||||
|
time.Date(2025, 8, 1, 23, 59, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 2, 1, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 3, 7, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 6, 7, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 6, 12, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 7, 5, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 8, 0, 30, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 8, 4, 30, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 8, 16, 30, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
returnValues := []time.Duration{
|
||||||
|
0, 7, 0, 3, 0, 2, 1, 0, 0,
|
||||||
|
}
|
||||||
|
for i := range returnValues {
|
||||||
|
returnValues[i] *= -24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
lastFired := time.Time{}
|
||||||
|
for i, nt := range nowTimes {
|
||||||
|
target := returnValues[i]
|
||||||
|
|
||||||
|
as.clock = fakeClock{now: nt}
|
||||||
|
|
||||||
|
ret := as.Check(since, lastFired)
|
||||||
|
|
||||||
|
if ret != target {
|
||||||
|
t.Fatalf("incorrect return value (%v != %v): i=%d, now=%+v, since=%+v, lastFired=%+v", ret, target, i, nt, since, lastFired)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ret != 0 {
|
||||||
|
lastFired = nt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimerSmallInterval(t *testing.T) {
|
||||||
|
as := NewDayTimerSet([]string{
|
||||||
|
"1", "1.1", "2", "3", "7",
|
||||||
|
}, -24*time.Hour)
|
||||||
|
|
||||||
|
since := time.Date(2025, 8, 9, 1, 0, 0, 0, time.UTC)
|
||||||
|
nowTimes := []time.Time{
|
||||||
|
time.Date(2025, 8, 1, 23, 59, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 2, 1, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 3, 7, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 6, 7, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 6, 7, 30, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 6, 12, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 7, 5, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 8, 0, 30, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 8, 4, 30, 0, 0, time.UTC),
|
||||||
|
time.Date(2025, 8, 8, 16, 30, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
returnValues := []time.Duration{
|
||||||
|
0, 7, 0, 3, 0, 0, 2, 1, 0, 0,
|
||||||
|
}
|
||||||
|
for i := range returnValues {
|
||||||
|
returnValues[i] *= -24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
lastFired := time.Time{}
|
||||||
|
for i, nt := range nowTimes {
|
||||||
|
target := returnValues[i]
|
||||||
|
|
||||||
|
as.clock = fakeClock{now: nt}
|
||||||
|
|
||||||
|
ret := as.Check(since, lastFired)
|
||||||
|
|
||||||
|
if ret != target {
|
||||||
|
t.Fatalf("incorrect return value (%v != %v): i=%d, now=%+v, since=%+v, lastFired=%+v", ret, target, i, nt, since, lastFired)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ret != 0 {
|
||||||
|
lastFired = nt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimerBruteForce(t *testing.T) {
|
||||||
|
as := NewDayTimerSet([]string{
|
||||||
|
"1", "2", "3", "7",
|
||||||
|
}, -24*time.Hour)
|
||||||
|
|
||||||
|
since := time.Date(2025, 8, 9, 1, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
returnedValues := map[time.Duration]time.Time{}
|
||||||
|
|
||||||
|
lastFired := time.Time{}
|
||||||
|
for dd := range 12 {
|
||||||
|
for hh := range 24 {
|
||||||
|
for mm := range 60 {
|
||||||
|
nt := time.Date(2025, 8, dd, hh, mm, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
as.clock = fakeClock{now: nt}
|
||||||
|
|
||||||
|
ret := as.Check(since, lastFired)
|
||||||
|
|
||||||
|
if dupe, ok := returnedValues[ret]; ok {
|
||||||
|
|
||||||
|
t.Fatalf("duplicate return value (%v): now=%+v, dupe=%+v, since=%+v, lastFired=%+v", ret, nt, dupe, since, lastFired)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ret != 0 {
|
||||||
|
returnedValues[ret] = nt
|
||||||
|
lastFired = nt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(returnedValues) != len(as.deltas) {
|
||||||
|
t.Fatalf("not all timers fired (%d/%d)", len(returnedValues), len(as.deltas))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user