Skip to content
Open
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
80 changes: 53 additions & 27 deletions cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,20 @@ import (
// specified by the schedule. It may be started, stopped, and the entries may
// be inspected while running.
type Cron struct {
entries []*Entry
chain Chain
stop chan struct{}
add chan *Entry
remove chan EntryID
snapshot chan chan []Entry
running bool
logger Logger
runningMu sync.Mutex
location *time.Location
parser ScheduleParser
nextID EntryID
jobWaiter sync.WaitGroup
entries []*Entry
chain Chain
stop chan struct{}
add chan *Entry
remove chan EntryID
snapshot chan chan []Entry
running bool
logger Logger
runningMu sync.Mutex
location *time.Location
parser ScheduleParser
nextID EntryID
jobWaiter sync.WaitGroup
skipIfLateBy time.Duration
}

// ScheduleParser is an interface for schedule spec parsers that return a Schedule
Expand Down Expand Up @@ -97,17 +98,17 @@ func (s byTime) Less(i, j int) bool {
//
// Available Settings
//
// Time Zone
// Description: The time zone in which schedules are interpreted
// Default: time.Local
// Time Zone
// Description: The time zone in which schedules are interpreted
// Default: time.Local
//
// Parser
// Description: Parser converts cron spec strings into cron.Schedules.
// Default: Accepts this spec: https://en.wikipedia.org/wiki/Cron
// Parser
// Description: Parser converts cron spec strings into cron.Schedules.
// Default: Accepts this spec: https://en.wikipedia.org/wiki/Cron
//
// Chain
// Description: Wrap submitted jobs to customize behavior.
// Default: A chain that recovers panics and logs them to stderr.
// Chain
// Description: Wrap submitted jobs to customize behavior.
// Default: A chain that recovers panics and logs them to stderr.
//
// See "cron.With*" to modify the default behavior.
func New(opts ...Option) *Cron {
Expand All @@ -123,6 +124,12 @@ func New(opts ...Option) *Cron {
logger: DefaultLogger,
location: time.Local,
parser: standardParser,
// The maximum allowable deviation between the triggering time and the expected time.
// If it exceeds this limit, this execution will be skipped.
// Under normal circumstances, the deviation is only at the millisecond level. Here,
// setting it to 10 minutes is merely as a fallback measure to prevent accidental triggering after
// the system goes into sleep mode or resumes snapshot restoration.
skipIfLateBy: 10 * time.Minute,
}
for _, opt := range opts {
opt(c)
Expand Down Expand Up @@ -263,17 +270,36 @@ func (c *Cron) run() {
select {
case now = <-timer.C:
now = now.In(c.location)
c.logger.Info("wake", "now", now)
now2 := time.Now().In(c.location)
c.logger.Info("wake", "now", now, "now2", now2)

// Run every entry whose next time was less than now
for _, e := range c.entries {
if e.Next.After(now) || e.Next.IsZero() {
break
}
c.startJob(e.WrappedJob)
e.Prev = e.Next
e.Next = e.Schedule.Next(now)
c.logger.Info("run", "now", now, "entry", e.ID, "next", e.Next)
c.logger.Info("entry wake", "now", now, "now2", now2, "entry", e.ID, "currentNext", e.Next)
// This loop is non-blocking, so the delay between now and now2 should be negligible.
// The only exception is when the process is restored from a snapshot (e.g. Windows Fast Startup),
// where now (from timer.C) may be a stale time like 23:59:59, while now2 (time.Now()) reflects
// the actual current time. Even if now2 is slightly off, it doesn't matter — skipIfLateBy
// provides the final safety net, and a small drift within the tolerance is acceptable.
lateBy := now2.Sub(e.Next)
if c.skipIfLateBy == 0 || lateBy < c.skipIfLateBy {
c.startJob(e.WrappedJob)
e.Prev = e.Next
e.Next = e.Schedule.Next(now2)
c.logger.Info("run", "now", now, "now2", now2, "entry", e.ID, "next", e.Next)
} else {
e.Prev = e.Next
e.Next = e.Schedule.Next(now2)
c.logger.Info("run: skipped for lateBy",
"now", now, "now2", now2,
"skipIfLateBy", c.skipIfLateBy,
"lateBy", lateBy,
"entry", e.ID, "next", e.Next,
)
}
}

case newEntry := <-c.add:
Expand Down
2 changes: 2 additions & 0 deletions issues/issue568/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
winsw.xml
*.log
1 change: 1 addition & 0 deletions issues/issue568/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- winsw <install | uninstall | start | stop | status>
58 changes: 58 additions & 0 deletions issues/issue568/builder/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package main

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)

func main() {
winswExampleConfig := `
<service>
<id>{{issue}}</id>
<name>{{issue}}</name>
<description>{{issue}}</description>

<executable>{{executable}}</executable>
<workingdirectory>{{workingdirectory}}</workingdirectory>
<stoptimeout>30 sec</stoptimeout>

<delayedAutoStart>true</delayedAutoStart>
<onfailure action="restart" delay="5000" />
<arguments>"0 0 0 * * *"</arguments>
</service>
`
winswConfigPath := "../winsw.xml"

var issue string
{
abs, err := filepath.Abs("../")
if err != nil {
panic(err)
}
issue = filepath.Base(abs)
}
execPath := fmt.Sprintf("../%s.exe", issue)
execAbsPath, err := filepath.Abs(execPath)
if err != nil {
panic(err)
}

_ = os.Remove(execPath)
output, err := exec.Command("go", "build", "-o", execPath, "../").CombinedOutput()
if err != nil {
fmt.Printf("output: `%s`\n", string(output))
panic(err)
}

winswConfig := winswExampleConfig
winswConfig = strings.ReplaceAll(winswConfig, "{{issue}}", issue)
winswConfig = strings.ReplaceAll(winswConfig, "{{executable}}", execAbsPath)
winswConfig = strings.ReplaceAll(winswConfig, "{{workingdirectory}}", filepath.Dir(execAbsPath))
err = os.WriteFile(winswConfigPath, []byte(winswConfig), os.ModePerm)
if err != nil {
panic(err)
}
}
47 changes: 47 additions & 0 deletions issues/issue568/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package main

import (
"log/slog"
"os"
"time"

"github.com/robfig/cron/v3"
)

type cronLogger struct{}

func (t *cronLogger) Printf(format string, args ...interface{}) {
slog.Info(args[0].(string), append([]any{
"logGroup", "cron",
}, args[1:]...)...)
}

func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))

var err error
// main
spec := "0 0 0 * * *"
if len(os.Args) == 2 && os.Args[1] != "" {
spec = os.Args[1]
}

slog.Info("start", "spec", spec, "args", os.Args)

logger := &cronLogger{}
c := cron.New(cron.WithSeconds(), cron.WithLogger(cron.VerbosePrintfLogger(logger)))

c.Start()

_, err = c.AddFunc(spec, func() {
now := time.Now()
slog.Info("job triggered", "spec", spec, "now", now)
})
if err != nil {
panic(err)
}

select {}
}
7 changes: 7 additions & 0 deletions option.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,10 @@ func WithLogger(logger Logger) Option {
c.logger = logger
}
}

// WithLogger uses the provided logger.
func SkipIfLateBy(skipIfLateBy time.Duration) Option {
return func(c *Cron) {
c.skipIfLateBy = skipIfLateBy
}
}