git.sr.ht/~pingoo/stdx@v0.0.0-20240218134121-094174641f6e/cron/cron.go (about)

     1  package cron
     2  
     3  import (
     4  	"context"
     5  	"sort"
     6  	"sync"
     7  	"time"
     8  )
     9  
    10  // Cron keeps track of any number of entries, invoking the associated func as
    11  // specified by the schedule. It may be started, stopped, and the entries may
    12  // be inspected while running.
    13  type Cron struct {
    14  	entries   []*Entry
    15  	chain     Chain
    16  	stop      chan struct{}
    17  	add       chan *Entry
    18  	remove    chan EntryID
    19  	snapshot  chan chan []Entry
    20  	running   bool
    21  	logger    Logger
    22  	runningMu sync.Mutex
    23  	location  *time.Location
    24  	parser    ScheduleParser
    25  	nextID    EntryID
    26  	jobWaiter sync.WaitGroup
    27  }
    28  
    29  // ScheduleParser is an interface for schedule spec parsers that return a Schedule
    30  type ScheduleParser interface {
    31  	Parse(spec string) (Schedule, error)
    32  }
    33  
    34  // Job is an interface for submitted cron jobs.
    35  type Job interface {
    36  	Run()
    37  }
    38  
    39  // Schedule describes a job's duty cycle.
    40  type Schedule interface {
    41  	// Next returns the next activation time, later than the given time.
    42  	// Next is invoked initially, and then each time the job is run.
    43  	Next(time.Time) time.Time
    44  }
    45  
    46  // EntryID identifies an entry within a Cron instance
    47  type EntryID int
    48  
    49  // Entry consists of a schedule and the func to execute on that schedule.
    50  type Entry struct {
    51  	// ID is the cron-assigned ID of this entry, which may be used to look up a
    52  	// snapshot or remove it.
    53  	ID EntryID
    54  
    55  	// Schedule on which this job should be run.
    56  	Schedule Schedule
    57  
    58  	// Next time the job will run, or the zero time if Cron has not been
    59  	// started or this entry's schedule is unsatisfiable
    60  	Next time.Time
    61  
    62  	// Prev is the last time this job was run, or the zero time if never.
    63  	Prev time.Time
    64  
    65  	// WrappedJob is the thing to run when the Schedule is activated.
    66  	WrappedJob Job
    67  
    68  	// Job is the thing that was submitted to cron.
    69  	// It is kept around so that user code that needs to get at the job later,
    70  	// e.g. via Entries() can do so.
    71  	Job Job
    72  }
    73  
    74  // Valid returns true if this is not the zero entry.
    75  func (e Entry) Valid() bool { return e.ID != 0 }
    76  
    77  // byTime is a wrapper for sorting the entry array by time
    78  // (with zero time at the end).
    79  type byTime []*Entry
    80  
    81  func (s byTime) Len() int      { return len(s) }
    82  func (s byTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
    83  func (s byTime) Less(i, j int) bool {
    84  	// Two zero times should return false.
    85  	// Otherwise, zero is "greater" than any other time.
    86  	// (To sort it at the end of the list.)
    87  	if s[i].Next.IsZero() {
    88  		return false
    89  	}
    90  	if s[j].Next.IsZero() {
    91  		return true
    92  	}
    93  	return s[i].Next.Before(s[j].Next)
    94  }
    95  
    96  // New returns a new Cron job runner, modified by the given options.
    97  //
    98  // Available Settings
    99  //
   100  //	Time Zone
   101  //	  Description: The time zone in which schedules are interpreted
   102  //	  Default:     time.Local
   103  //
   104  //	Parser
   105  //	  Description: Parser converts cron spec strings into cron.Schedules.
   106  //	  Default:     Accepts this spec: https://en.wikipedia.org/wiki/Cron
   107  //
   108  //	Chain
   109  //	  Description: Wrap submitted jobs to customize behavior.
   110  //	  Default:     A chain that recovers panics and logs them to stderr.
   111  //
   112  // See "cron.With*" to modify the default behavior.
   113  func New(opts ...Option) *Cron {
   114  	c := &Cron{
   115  		entries:   nil,
   116  		chain:     NewChain(),
   117  		add:       make(chan *Entry),
   118  		stop:      make(chan struct{}),
   119  		snapshot:  make(chan chan []Entry),
   120  		remove:    make(chan EntryID),
   121  		running:   false,
   122  		runningMu: sync.Mutex{},
   123  		logger:    DefaultLogger,
   124  		location:  time.Local,
   125  		parser:    standardParser,
   126  	}
   127  	for _, opt := range opts {
   128  		opt(c)
   129  	}
   130  	return c
   131  }
   132  
   133  // FuncJob is a wrapper that turns a func() into a cron.Job
   134  type FuncJob func()
   135  
   136  func (f FuncJob) Run() { f() }
   137  
   138  // AddFunc adds a func to the Cron to be run on the given schedule.
   139  // The spec is parsed using the time zone of this Cron instance as the default.
   140  // An opaque ID is returned that can be used to later remove it.
   141  func (c *Cron) AddFunc(spec string, cmd func()) (EntryID, error) {
   142  	return c.AddJob(spec, FuncJob(cmd))
   143  }
   144  
   145  // AddJob adds a Job to the Cron to be run on the given schedule.
   146  // The spec is parsed using the time zone of this Cron instance as the default.
   147  // An opaque ID is returned that can be used to later remove it.
   148  func (c *Cron) AddJob(spec string, cmd Job) (EntryID, error) {
   149  	schedule, err := c.parser.Parse(spec)
   150  	if err != nil {
   151  		return 0, err
   152  	}
   153  	return c.Schedule(schedule, cmd), nil
   154  }
   155  
   156  // Schedule adds a Job to the Cron to be run on the given schedule.
   157  // The job is wrapped with the configured Chain.
   158  func (c *Cron) Schedule(schedule Schedule, cmd Job) EntryID {
   159  	c.runningMu.Lock()
   160  	defer c.runningMu.Unlock()
   161  	c.nextID++
   162  	entry := &Entry{
   163  		ID:         c.nextID,
   164  		Schedule:   schedule,
   165  		WrappedJob: c.chain.Then(cmd),
   166  		Job:        cmd,
   167  	}
   168  	if !c.running {
   169  		c.entries = append(c.entries, entry)
   170  	} else {
   171  		c.add <- entry
   172  	}
   173  	return entry.ID
   174  }
   175  
   176  // Entries returns a snapshot of the cron entries.
   177  func (c *Cron) Entries() []Entry {
   178  	c.runningMu.Lock()
   179  	defer c.runningMu.Unlock()
   180  	if c.running {
   181  		replyChan := make(chan []Entry, 1)
   182  		c.snapshot <- replyChan
   183  		return <-replyChan
   184  	}
   185  	return c.entrySnapshot()
   186  }
   187  
   188  // Location gets the time zone location
   189  func (c *Cron) Location() *time.Location {
   190  	return c.location
   191  }
   192  
   193  // Entry returns a snapshot of the given entry, or nil if it couldn't be found.
   194  func (c *Cron) Entry(id EntryID) Entry {
   195  	for _, entry := range c.Entries() {
   196  		if id == entry.ID {
   197  			return entry
   198  		}
   199  	}
   200  	return Entry{}
   201  }
   202  
   203  // Remove an entry from being run in the future.
   204  func (c *Cron) Remove(id EntryID) {
   205  	c.runningMu.Lock()
   206  	defer c.runningMu.Unlock()
   207  	if c.running {
   208  		c.remove <- id
   209  	} else {
   210  		c.removeEntry(id)
   211  	}
   212  }
   213  
   214  // Start the cron scheduler in its own goroutine, or no-op if already started.
   215  func (c *Cron) Start() {
   216  	c.runningMu.Lock()
   217  	defer c.runningMu.Unlock()
   218  	if c.running {
   219  		return
   220  	}
   221  	c.running = true
   222  	go c.run()
   223  }
   224  
   225  // Run the cron scheduler, or no-op if already running.
   226  func (c *Cron) Run() {
   227  	c.runningMu.Lock()
   228  	if c.running {
   229  		c.runningMu.Unlock()
   230  		return
   231  	}
   232  	c.running = true
   233  	c.runningMu.Unlock()
   234  	c.run()
   235  }
   236  
   237  // run the scheduler.. this is private just due to the need to synchronize
   238  // access to the 'running' state variable.
   239  func (c *Cron) run() {
   240  	c.logger.Info("start")
   241  
   242  	// Figure out the next activation times for each entry.
   243  	now := c.now()
   244  	for _, entry := range c.entries {
   245  		entry.Next = entry.Schedule.Next(now)
   246  		c.logger.Info("schedule", "now", now, "entry", entry.ID, "next", entry.Next)
   247  	}
   248  
   249  	for {
   250  		// Determine the next entry to run.
   251  		sort.Sort(byTime(c.entries))
   252  
   253  		var timer *time.Timer
   254  		if len(c.entries) == 0 || c.entries[0].Next.IsZero() {
   255  			// If there are no entries yet, just sleep - it still handles new entries
   256  			// and stop requests.
   257  			timer = time.NewTimer(100000 * time.Hour)
   258  		} else {
   259  			timer = time.NewTimer(c.entries[0].Next.Sub(now))
   260  		}
   261  
   262  		for {
   263  			select {
   264  			case now = <-timer.C:
   265  				now = now.In(c.location)
   266  				c.logger.Info("wake", "now", now)
   267  
   268  				// Run every entry whose next time was less than now
   269  				for _, e := range c.entries {
   270  					if e.Next.After(now) || e.Next.IsZero() {
   271  						break
   272  					}
   273  					c.startJob(e.WrappedJob)
   274  					e.Prev = e.Next
   275  					e.Next = e.Schedule.Next(now)
   276  					c.logger.Info("run", "now", now, "entry", e.ID, "next", e.Next)
   277  				}
   278  
   279  			case newEntry := <-c.add:
   280  				timer.Stop()
   281  				now = c.now()
   282  				newEntry.Next = newEntry.Schedule.Next(now)
   283  				c.entries = append(c.entries, newEntry)
   284  				c.logger.Info("added", "now", now, "entry", newEntry.ID, "next", newEntry.Next)
   285  
   286  			case replyChan := <-c.snapshot:
   287  				replyChan <- c.entrySnapshot()
   288  				continue
   289  
   290  			case <-c.stop:
   291  				timer.Stop()
   292  				c.logger.Info("stop")
   293  				return
   294  
   295  			case id := <-c.remove:
   296  				timer.Stop()
   297  				now = c.now()
   298  				c.removeEntry(id)
   299  				c.logger.Info("removed", "entry", id)
   300  			}
   301  
   302  			break
   303  		}
   304  	}
   305  }
   306  
   307  // startJob runs the given job in a new goroutine.
   308  func (c *Cron) startJob(j Job) {
   309  	c.jobWaiter.Add(1)
   310  	go func() {
   311  		defer c.jobWaiter.Done()
   312  		j.Run()
   313  	}()
   314  }
   315  
   316  // now returns current time in c location
   317  func (c *Cron) now() time.Time {
   318  	return time.Now().In(c.location)
   319  }
   320  
   321  // Stop stops the cron scheduler if it is running; otherwise it does nothing.
   322  // A context is returned so the caller can wait for running jobs to complete.
   323  func (c *Cron) Stop() context.Context {
   324  	c.runningMu.Lock()
   325  	defer c.runningMu.Unlock()
   326  	if c.running {
   327  		c.stop <- struct{}{}
   328  		c.running = false
   329  	}
   330  	ctx, cancel := context.WithCancel(context.Background())
   331  	go func() {
   332  		c.jobWaiter.Wait()
   333  		cancel()
   334  	}()
   335  	return ctx
   336  }
   337  
   338  // entrySnapshot returns a copy of the current cron entry list.
   339  func (c *Cron) entrySnapshot() []Entry {
   340  	var entries = make([]Entry, len(c.entries))
   341  	for i, e := range c.entries {
   342  		entries[i] = *e
   343  	}
   344  	return entries
   345  }
   346  
   347  func (c *Cron) removeEntry(id EntryID) {
   348  	var entries []*Entry
   349  	for _, e := range c.entries {
   350  		if e.ID != id {
   351  			entries = append(entries, e)
   352  		}
   353  	}
   354  	c.entries = entries
   355  }