Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module github.com/sunshineplan/utils

go 1.24
go 1.25
49 changes: 37 additions & 12 deletions scheduler/clock.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@ var (
_ Schedule = clockSched{}
)

// Clock represents a specific time of day (hour, minute, second) that can be used
// as a schedule condition. Each field (hour, min, sec) can be optional, allowing
// partial matching (e.g., “every minute at second 0” or “every day at 12:00:*”).
type Clock struct {
clock.Clock
hour, min, sec bool
}

// atClock creates a new clock instance from the given hour, minute, and second values.
// A value of -1 is treated as a wildcard and replaced by 0 internally.
func atClock(hour, min, sec int) clock.Clock {
if hour == -1 {
hour = 0
Expand All @@ -30,6 +35,8 @@ func atClock(hour, min, sec int) clock.Clock {
return clock.New(hour, min, sec)
}

// AtClock creates a new Clock schedule at the specified hour, minute, and second.
// Use -1 for a wildcard (any value). Panics on invalid input.
func AtClock(hour, min, sec int) *Clock {
if hour > 23 || hour < -1 ||
min > 59 || min < -1 ||
Expand All @@ -50,20 +57,26 @@ func AtClock(hour, min, sec int) *Clock {
return &c
}

// FullClock returns a Clock with all fields as wildcards (matches any time).
func FullClock() *Clock { return new(Clock) }

// AtHour returns a Clock that triggers at the specified hour (minute and second = 0).
func AtHour(hour int) *Clock {
return AtClock(hour, 0, 0)
}

// AtMinute returns a Clock that triggers at the specified minute of any hour.
func AtMinute(min int) *Clock {
return AtClock(-1, min, 0)
}

// AtSecond returns a Clock that triggers at the specified second of any minute.
func AtSecond(sec int) *Clock {
return AtClock(-1, -1, sec)
}

// ClockFromString parses a clock string (e.g. "12:30:00") into a Clock schedule.
// Panics if the string cannot be parsed.
func ClockFromString(str string) *Clock {
c, err := clock.Parse(str)
if err != nil {
Expand All @@ -72,6 +85,7 @@ func ClockFromString(str string) *Clock {
return &Clock{c, true, true, true}
}

// HourSchedule creates a multi-schedule that triggers on any of the specified hours.
func HourSchedule(hour ...int) Schedule {
var s multiSched
for _, hour := range hour {
Expand All @@ -80,6 +94,7 @@ func HourSchedule(hour ...int) Schedule {
return s
}

// MinuteSchedule creates a multi-schedule that triggers on any of the specified minutes.
func MinuteSchedule(min ...int) Schedule {
var s multiSched
for _, min := range min {
Expand All @@ -88,6 +103,7 @@ func MinuteSchedule(min ...int) Schedule {
return s
}

// SecondSchedule creates a multi-schedule that triggers on any of the specified seconds.
func SecondSchedule(sec ...int) Schedule {
var s multiSched
for _, sec := range sec {
Expand All @@ -96,6 +112,7 @@ func SecondSchedule(sec ...int) Schedule {
return s
}

// Hour sets the hour field of the Clock (or disables it if -1).
func (c *Clock) Hour(hour int) *Clock {
if hour > 23 || hour < -1 {
panic(fmt.Sprint("invalid hour ", hour))
Expand All @@ -109,6 +126,7 @@ func (c *Clock) Hour(hour int) *Clock {
return c
}

// Minute sets the minute field of the Clock (or disables it if -1).
func (c *Clock) Minute(min int) *Clock {
if min > 59 || min < -1 {
panic(fmt.Sprint("invalid minute ", min))
Expand All @@ -122,6 +140,7 @@ func (c *Clock) Minute(min int) *Clock {
return c
}

// Second sets the second field of the Clock (or disables it if -1).
func (c *Clock) Second(sec int) *Clock {
if sec > 59 || sec < -1 {
panic(fmt.Sprint("invalid second ", sec))
Expand All @@ -135,13 +154,17 @@ func (c *Clock) Second(sec int) *Clock {
return c
}

// IsMatched reports whether the given time matches this Clock.
// Wildcard fields (hour/min/sec=false) are ignored during comparison.
func (c Clock) IsMatched(t time.Time) bool {
hour, min, sec := t.Clock()
return (!c.hour || c.Clock.Hour() == hour) &&
(!c.min || c.Clock.Minute() == min) &&
(!c.sec || c.Clock.Second() == sec)
}

// Next returns the next time that matches this Clock configuration
// after the given reference time. Handles wildcards intelligently.
func (c Clock) Next(t time.Time) (next time.Time) {
t = t.Truncate(time.Second)
if c.IsMatched(t) {
Expand All @@ -165,7 +188,7 @@ func (c Clock) Next(t time.Time) (next time.Time) {
hour = t.Hour()
}
switch next = time.Date(year, month, day, hour, min, sec, 0, t.Location()); t.Compare(next) {
case 1:
case 1: // next < t
if !c.sec {
next = next.Add(-time.Duration(sec) * time.Second)
}
Expand All @@ -181,19 +204,20 @@ func (c Clock) Next(t time.Time) (next time.Time) {
return next.Add(time.Hour)
}
return next.AddDate(0, 0, 1)
case -1:
case -1: // next > t
if !c.sec {
next = next.Add(-time.Duration(sec) * time.Second)
}
if !c.min && t.Hour() != hour {
next = next.Add(-time.Duration(min) * time.Minute)
}
return
default:
default: // equal
return t
}
}

// String returns a human-readable representation such as "12:--:--" or "14:30:00".
func (c Clock) String() string {
var hour, min, sec string
if !c.hour {
Expand All @@ -214,23 +238,32 @@ func (c Clock) String() string {
return fmt.Sprintf("%s:%s:%s", hour, min, sec)
}

// clockSched defines a schedule for a time interval within a day,
// repeatedly triggering at a fixed duration between start and end.
type clockSched struct {
start, end *Clock
d time.Duration
}

// ClockSchedule creates a new schedule that triggers every d duration
// between start and end (inclusive). The duration must be at least one second
// and an integer multiple of a second.
func ClockSchedule(start, end *Clock, d time.Duration) Schedule {
if d < time.Second || d%time.Second != 0 {
panic("the minimum duration is one second and must be a multiple of seconds")
}
return clockSched{start, end, d}
}

// IsMatched reports whether the given time falls within [start, end]
// and aligns with the configured duration d.
func (s clockSched) IsMatched(t time.Time) bool {
start, end, tc := s.start, s.end, AtClock(t.Clock()).Clock
return (start.Equal(tc) || start.Before(tc) && end.After(tc) || end.Equal(tc)) && tc.Since(start.Clock)%s.d == 0
}

// Next returns the next matching time within the configured range.
// If none is found, it wraps to the next occurrence of start.
func (s clockSched) Next(t time.Time) time.Time {
if s.IsMatched(t) {
t = t.Add(time.Second)
Expand All @@ -244,15 +277,7 @@ func (s clockSched) Next(t time.Time) time.Time {
return s.start.Next(t)
}

func (s clockSched) TickerDuration() time.Duration {
if s.start.Clock.Second() != 0 {
return time.Second
} else if s.start.Clock.Minute() != 0 && s.d%time.Minute == 0 {
return time.Minute
}
return s.d
}

// String returns a readable representation like "08:00:00-18:00:00(every 30m0s)".
func (s clockSched) String() string {
return fmt.Sprintf("%q-%q(every %s)", s.start, s.end, s.d)
}
34 changes: 34 additions & 0 deletions scheduler/complex.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,29 @@ var (
_ complexSched = condSched{}
)

// complexSched defines an internal interface for composite schedules.
// It extends Schedule with initialization and introspection capabilities.
type complexSched interface {
// IsMatched reports whether the given time matches the composite condition.
IsMatched(time.Time) bool
// Next returns the next time that satisfies the composite condition.
Next(time.Time) time.Time
// String returns a human-readable representation.
String() string

// init initializes internal states or nested schedules using the given start time.
init(t time.Time)
// len returns the number of sub-schedules contained in this composite schedule.
len() int
}

// complex is a type constraint used for generic initialization of schedule slices.
type complex interface {
~[]Schedule
}

// initComplexSched initializes all sub-schedules that implement complexSched or tickerSched.
// It ensures that nested or periodic schedules have their starting point properly set.
func initComplexSched[sche complex](s sche, t time.Time) {
for _, s := range s {
if i, ok := s.(complexSched); ok {
Expand All @@ -36,20 +46,27 @@ func initComplexSched[sche complex](s sche, t time.Time) {
}
}

// multiSched represents a composite schedule that matches if *any*
// of its sub-schedules match — i.e., a logical OR operation.
type multiSched []Schedule

// MultiSchedule creates a new schedule that triggers when any of the provided
// schedules match. Equivalent to a logical OR of all schedules.
func MultiSchedule(schedules ...Schedule) Schedule {
return multiSched(schedules)
}

// init initializes nested schedules recursively.
func (s multiSched) init(t time.Time) {
initComplexSched(s, t)
}

// len returns the number of contained sub-schedules.
func (s multiSched) len() int {
return len(s)
}

// IsMatched returns true if any sub-schedule matches the given time.
func (s multiSched) IsMatched(t time.Time) bool {
for _, i := range s {
if i.IsMatched(t) {
Expand All @@ -59,6 +76,8 @@ func (s multiSched) IsMatched(t time.Time) bool {
return false
}

// Next returns the earliest next time among all sub-schedules.
// If no valid next time exists, it returns a zero time.
func (s multiSched) Next(t time.Time) (next time.Time) {
for _, i := range s {
if t := i.Next(t); next.IsZero() || !t.IsZero() && t.Before(next) {
Expand All @@ -68,6 +87,7 @@ func (s multiSched) Next(t time.Time) (next time.Time) {
return
}

// String returns a readable representation of the multi-schedule.
func (s multiSched) String() string {
switch len(s) {
case 0:
Expand All @@ -87,20 +107,27 @@ func (s multiSched) String() string {
}
}

// condSched represents a composite schedule that matches only if *all*
// of its sub-schedules match — i.e., a logical AND operation.
type condSched []Schedule

// ConditionSchedule creates a new schedule that triggers only when all
// of the provided schedules match simultaneously. Equivalent to a logical AND.
func ConditionSchedule(schedules ...Schedule) Schedule {
return condSched(schedules)
}

// init initializes nested schedules recursively.
func (s condSched) init(t time.Time) {
initComplexSched(s, t)
}

// len returns the number of contained sub-schedules.
func (s condSched) len() int {
return len(s)
}

// IsMatched returns true only if all sub-schedules match the given time.
func (s condSched) IsMatched(t time.Time) bool {
if s.len() == 0 {
return false
Expand All @@ -113,15 +140,21 @@ func (s condSched) IsMatched(t time.Time) bool {
return true
}

// Next returns the next time that satisfies all sub-schedules simultaneously.
// If there are no schedules, it returns zero time.
// If the current time already matches, it advances by one second to find the next occurrence.
func (s condSched) Next(t time.Time) (next time.Time) {
if l := len(s); l == 0 {
return time.Time{}
} else if l == 1 {
return s[0].Next(t)
}
// Avoid returning the same time repeatedly if it already matches.
if s.IsMatched(t) {
t = t.Add(time.Second)
}
// Increment one second at a time until all conditions match,
// but limit the search to one year to avoid infinite loops.
for next = t.Truncate(time.Second); !s.IsMatched(next); next = next.Add(time.Second) {
if next.Sub(t) >= time.Hour*24*366 {
return time.Time{}
Expand All @@ -130,6 +163,7 @@ func (s condSched) Next(t time.Time) (next time.Time) {
return
}

// String returns a readable representation of the condition schedule.
func (s condSched) String() string {
switch len(s) {
case 0:
Expand Down
13 changes: 13 additions & 0 deletions scheduler/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,25 @@ import (
"github.com/sunshineplan/utils/container"
)

// subscriber is a global registry mapping event channels to their target times.
// It is used internally by all running schedulers to receive tick events.
var subscriber = container.NewMap[chan Event, time.Time]()

// Event represents a time event emitted by the scheduler engine.
// It carries both the actual trigger time (Time) and the intended schedule time (Goal).
// If Missed is true, the event was triggered after its scheduled Goal.
type Event struct {
Time time.Time
Goal time.Time
Missed bool
}

// init launches a global background goroutine that ticks every second.
// For each tick, it compares the current time with all subscribed times,
// and sends corresponding Event values to their channels.
//
// This mechanism allows multiple Scheduler instances to share the same
// centralized time source and operate concurrently.
func init() {
go func() {
for t := range time.NewTicker(time.Second).C {
Expand All @@ -31,10 +42,12 @@ func init() {
}()
}

// subscribe registers a channel to receive an Event when the given time arrives.
func subscribe(t time.Time, c chan Event) {
subscriber.Swap(c, t.Truncate(time.Second))
}

// unsubscribe removes a previously registered channel from the subscriber map.
func unsubscribe(c chan Event) {
subscriber.Delete(c)
}
Loading
Loading