Frode Jacobsen

Poor man's cronjob in Go

I recently had use for a recurring job in a simple Go web application. Due to limitations on the number of processes that can access a database file in Bolt I couldn't use cron itself. Instead of grabbing the first and biggest dependency I could find, I made use of the built-in time.Ticker to create a poor man's in-process cronjob.

At it's most basic, you can get a recurring job running with just a goroutine and a ticker:

func Repeat(interval time.Duration) {
  ticker := time.NewTicker(interval)
  go func() {
    for {
      t := <-ticker.C
      fmt.Println("tick", t)
    }
  }()
}

This function first creates a ticker that will send a message on the channel ticker.C at a regular interval given as a time.Duration. It then starts a goroutine that loops forever, waiting for messages on the ticker channel. Whenever a message (a time.Time denoting when the tick occured) is received, it just prints the timestamp and the loop starts over again.

To make this a bit more useful, we can modify the function to accept another function that we will call whenever a tick occurs:

func Repeat(f func(), interval time.Duration) {
  ticker := time.NewTicker(interval)
  go func() {
    for {
      <-ticker.C
      f()
    }
  }()
}

We can use this to e.g. update a counter every second:

func main() {
  c := 0
  go Repeat(func() {
    c++
    println(c)
  }, 1*time.Second)
  time.Sleep(5*time.Second)
}

If all we need is to do *something* at a set interval and don't care about what happens when the application is asked to stop, we don't have to take this any further. This will run and do it's thing until the process terminates. But let's say we want to do some cleanup before the application shuts down. Maybe we want to signal some other service that we're dropping the connection, or maybe we want to clean up some temporary files. We need some way to signal our loop that it's time to stop:

func Repeat(f func(), i time.Duration) chan bool {
  t := time.NewTicker(i)
  done := make(chan bool, 1)
  go func() {
    for {
      select {
      case <-t.C:
        f()
      case <-done:
        t.Stop()
        return
      }
    }
  }()
  return done
}

Here we've added another channel, done, that we can use to pass messages to our goroutine. We're also making use of select which let's us wait on multiple channels at the same time. Remember that for every tick we receive a message on t.C. When that happens, we execute f just like before. However, if we receive a message on the new done channel, we'll instead stop the ticker and return from the loop. We can extend this to also call a finalizer function before returning, allowing us to perform any necessary cleanup.

Now, putting this all together we can create a little Worker that can be reused whenever we need to run something on a set schedule. It's in no way as powerful as cron, and the scheduling options is limited to the ticker interval, but it can get the job done in a lot of cases. Here, I've created a struct to hold the function to call on every tick, the cleanup function to call at the very end, the interval at which it's supposed to run, as well as the ticker itself and the channel to signal that we're done. I've also separated out the run loop into it's own method, and added Start() and Stop() methods to manage the lifecycle of the worker. We also add a nil guard before calling the finalizer in order to allow us to simply not pass a finalizer if it's not needed. I've deliberatly left out the nil guard before calling w.Act(), becuase not passing that to the worker would make it completely pointless.

package main

import "time"

type Worker struct {
  Act      func()
  Finalize func()
  Interval time.Duration
  done     chan bool
  ticker   *time.Ticker
}

func (w *Worker) Start() {
  w.ticker = time.NewTicker(w.Interval)
  w.done = make(chan bool, 1)
  go w.run()
}

func (w *Worker) run() {
  for {
    select {
    case <-w.ticker.C:
      w.Act()
    case <-w.done:
      return
    }
  }
}

func (w *Worker) Stop() {
  w.ticker.Stop()
  w.done <- true
  if w.Finalize != nil {            
    w.Finalize()
  }
}

And finally, a toy example utilizing the Worker to increment a counter, and in the end reset it to 0 again:

package main

import "time"

func main() {
  c := 0
  w := &Worker{
    Act:      func() { c++ },
    Finalize: func() { c = 0 },
    Interval: 99 * time.Millisecond,
  }
  w.Start()
  println(c) // 0
  time.Sleep(1 * time.Second)
  println(c) // 10
  time.Sleep(1 * time.Second)
  println(c) // 20
  w.Stop()
  println(c) // 0
}

From here, it's possible to add functionality for e.g. restarting a stopped worker. We can also connect the worker's lifecycle to some API endpoint, and by utilizing the Reset() function of the ticker we can even change the interval the worker is running on without stopping our webserver.

So there it is; a pretty straight-forward (and useful!) job runner in 30-something lines of Go. It's not cron, and I'm not even sure it deserves to be called a poor man's cronjob, but it does the trick and I've currently got some variation of this running in several different services at the time of writing. If you want the full working toy example you can grab it here.

2024-10-11