First draft
This commit is contained in:
128
scheduler/scheduler.go
Normal file
128
scheduler/scheduler.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
"woke/db"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// AlarmEvent is sent when an alarm triggers.
|
||||
type AlarmEvent struct {
|
||||
Alarm db.Alarm
|
||||
}
|
||||
|
||||
// Scheduler checks alarms every second and fires events when they trigger.
|
||||
type Scheduler struct {
|
||||
store *db.Store
|
||||
events chan AlarmEvent
|
||||
stop chan struct{}
|
||||
mu sync.Mutex
|
||||
parser cron.Parser
|
||||
}
|
||||
|
||||
func New(store *db.Store) *Scheduler {
|
||||
return &Scheduler{
|
||||
store: store,
|
||||
events: make(chan AlarmEvent, 10),
|
||||
stop: make(chan struct{}),
|
||||
parser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow),
|
||||
}
|
||||
}
|
||||
|
||||
// Events returns the channel that receives alarm trigger events.
|
||||
func (s *Scheduler) Events() <-chan AlarmEvent {
|
||||
return s.events
|
||||
}
|
||||
|
||||
// Start begins the alarm checking loop.
|
||||
func (s *Scheduler) Start() {
|
||||
go s.loop()
|
||||
}
|
||||
|
||||
// Stop halts the scheduler.
|
||||
func (s *Scheduler) Stop() {
|
||||
close(s.stop)
|
||||
}
|
||||
|
||||
func (s *Scheduler) loop() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Track which alarms already fired this minute to avoid duplicates
|
||||
firedThisMinute := make(map[int]string) // alarm ID -> "HH:MM" when last fired
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
s.check(now, firedThisMinute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) check(now time.Time, firedThisMinute map[int]string) {
|
||||
alarms, err := s.store.ListAlarms()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
currentMinute := now.Format("15:04")
|
||||
|
||||
// Clean up old entries from firedThisMinute
|
||||
for id, minute := range firedThisMinute {
|
||||
if minute != currentMinute {
|
||||
delete(firedThisMinute, id)
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range alarms {
|
||||
if !a.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Already fired this minute
|
||||
if firedThisMinute[a.ID] == currentMinute {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.shouldTrigger(a, now) {
|
||||
firedThisMinute[a.ID] = currentMinute
|
||||
_ = s.store.MarkTriggered(a.ID)
|
||||
|
||||
// Disable one-shot alarms after triggering
|
||||
if a.Trigger == "once" {
|
||||
_ = s.store.ToggleAlarm(a.ID)
|
||||
}
|
||||
|
||||
select {
|
||||
case s.events <- AlarmEvent{Alarm: a}:
|
||||
default:
|
||||
// Don't block if channel is full
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) shouldTrigger(a db.Alarm, now time.Time) bool {
|
||||
if a.Trigger == "once" {
|
||||
// Simple time match: HH:MM
|
||||
return now.Format("15:04") == a.Time
|
||||
}
|
||||
|
||||
// Cron expression
|
||||
sched, err := s.parser.Parse(a.Trigger)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if now falls within the current minute of the cron schedule.
|
||||
// We truncate to the minute and check if the next time after (now - 1min) is now.
|
||||
truncated := now.Truncate(time.Minute)
|
||||
prev := truncated.Add(-time.Minute)
|
||||
next := sched.Next(prev)
|
||||
|
||||
return next.Equal(truncated)
|
||||
}
|
||||
Reference in New Issue
Block a user