From 39d799cef53a97d9becfb269f41840976d74357a Mon Sep 17 00:00:00 2001 From: liruohrh <2372221537@qq.com> Date: Sat, 25 Apr 2026 13:53:04 +0800 Subject: [PATCH] fix: #568, skip if now diff with task's invoke time to big --- cron.go | 80 ++++++++++++++++++++++----------- issues/issue568/.gitignore | 2 + issues/issue568/README.md | 1 + issues/issue568/builder/main.go | 58 ++++++++++++++++++++++++ issues/issue568/main.go | 47 +++++++++++++++++++ option.go | 7 +++ 6 files changed, 168 insertions(+), 27 deletions(-) create mode 100644 issues/issue568/.gitignore create mode 100644 issues/issue568/README.md create mode 100644 issues/issue568/builder/main.go create mode 100644 issues/issue568/main.go diff --git a/cron.go b/cron.go index c7e91766..ce26beb3 100644 --- a/cron.go +++ b/cron.go @@ -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 @@ -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 { @@ -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) @@ -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: diff --git a/issues/issue568/.gitignore b/issues/issue568/.gitignore new file mode 100644 index 00000000..55242bee --- /dev/null +++ b/issues/issue568/.gitignore @@ -0,0 +1,2 @@ +winsw.xml +*.log \ No newline at end of file diff --git a/issues/issue568/README.md b/issues/issue568/README.md new file mode 100644 index 00000000..1c250f32 --- /dev/null +++ b/issues/issue568/README.md @@ -0,0 +1 @@ +- winsw \ No newline at end of file diff --git a/issues/issue568/builder/main.go b/issues/issue568/builder/main.go new file mode 100644 index 00000000..a89b22cf --- /dev/null +++ b/issues/issue568/builder/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func main() { + winswExampleConfig := ` + + {{issue}} + {{issue}} + {{issue}} + + {{executable}} + {{workingdirectory}} + 30 sec + + true + + "0 0 0 * * *" + +` + 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) + } +} diff --git a/issues/issue568/main.go b/issues/issue568/main.go new file mode 100644 index 00000000..902c69eb --- /dev/null +++ b/issues/issue568/main.go @@ -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 {} +} diff --git a/option.go b/option.go index 09e4278e..05fe3064 100644 --- a/option.go +++ b/option.go @@ -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 + } +}