package notify import ( "context" "sort" "sync" "time" "github.com/coder/quartz" ) // Notifier triggers callbacks at given intervals until some event happens. The // intervals (e.g. 10 minute warning, 5 minute warning) are given in the // countdown. The Notifier periodically polls the condition to get the time of // the event (the Condition's deadline) and the callback. The callback is // called at most once per entry in the countdown, the first time the time to // the deadline is shorter than the duration. type Notifier struct { ctx context.Context cancel context.CancelFunc pollDone chan struct{} lock sync.Mutex condition Condition notifiedAt map[time.Duration]bool countdown []time.Duration // for testing clock quartz.Clock } // Condition is a function that gets executed periodically, and receives the // current time as an argument. // - It should return the deadline for the notification, as well as a // callback function to execute. If deadline is the zero // time, callback will not be executed. // - Callback is executed once for every time the difference between deadline // and the current time is less than an element of countdown. // - To enforce a minimum interval between consecutive callbacks, truncate // the returned deadline to the minimum interval. type Condition func(now time.Time) (deadline time.Time, callback func()) type Option func(*Notifier) // WithTestClock is used in tests to inject a mock Clock func WithTestClock(clk quartz.Clock) Option { return func(n *Notifier) { n.clock = clk } } // New returns a Notifier that calls cond once every time it polls. // - Duplicate values are removed from countdown, and it is sorted in // descending order. func New(cond Condition, interval time.Duration, countdown []time.Duration, opts ...Option) *Notifier { // Ensure countdown is sorted in descending order and contains no duplicates. ct := unique(countdown) sort.Slice(ct, func(i, j int) bool { return ct[i] < ct[j] }) ctx, cancel := context.WithCancel(context.Background()) n := &Notifier{ ctx: ctx, cancel: cancel, pollDone: make(chan struct{}), countdown: ct, condition: cond, notifiedAt: make(map[time.Duration]bool), clock: quartz.NewReal(), } for _, opt := range opts { opt(n) } go n.poll(interval) return n } // poll polls once immediately, and then periodically according to the interval. // Poll exits when ticker is closed. func (n *Notifier) poll(interval time.Duration) { defer close(n.pollDone) // poll once immediately _ = n.pollOnce() tkr := n.clock.TickerFunc(n.ctx, interval, n.pollOnce, "notifier", "poll") _ = tkr.Wait() } func (n *Notifier) Close() { n.cancel() <-n.pollDone } // pollOnce only returns an error so it matches the signature expected of TickerFunc // nolint: revive // bare returns are fine here func (n *Notifier) pollOnce() (_ error) { tick := n.clock.Now() n.lock.Lock() defer n.lock.Unlock() deadline, callback := n.condition(tick) if deadline.IsZero() { return } timeRemaining := deadline.Sub(tick) for _, tock := range n.countdown { if n.notifiedAt[tock] { continue } if timeRemaining > tock { continue } callback() n.notifiedAt[tock] = true return } return } func unique(ds []time.Duration) []time.Duration { m := make(map[time.Duration]bool) for _, d := range ds { m[d] = true } var ks []time.Duration for k := range m { ks = append(ks, k) } return ks }