├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── README.md ├── cron.go ├── cron_test.go ├── executor.go ├── executor_test.go ├── go.mod ├── go.sum ├── runner.go ├── scheduler.go ├── scheduler_test.go ├── task.go ├── task_test.go ├── trigger.go └── trigger_test.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | codecov: codecov/codecov@3.1.1 5 | 6 | jobs: 7 | build: 8 | docker: 9 | - image: cimg/go:1.18 10 | steps: 11 | - checkout 12 | - restore_cache: 13 | keys: 14 | - go-mod-v4-{{ checksum "go.sum" }} 15 | - run: 16 | name: Install Dependencies 17 | command: go get -t -v ./... 18 | - save_cache: 19 | key: go-mod-v4-{{ checksum "go.sum" }} 20 | paths: 21 | - "/go/pkg/mod" 22 | - run: 23 | name: Run tests 24 | command: go test -coverprofile=coverage.txt -covermode=atomic 25 | - codecov/upload 26 | workflows: 27 | build-workflow: 28 | jobs: 29 | - build 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Procyon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Chrono Logo](https://user-images.githubusercontent.com/5354910/196008920-1ca88967-3d7d-449c-b165-fe38c5e1fb57.png) 2 | # Chrono 3 | [![Go Report Card](https://goreportcard.com/badge/codnect.io/chrono)](https://goreportcard.com/report/codnect.io/chrono) 4 | [![CircleCI](https://dl.circleci.com/status-badge/img/gh/procyon-projects/chrono/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/procyon-projects/chrono/tree/main) 5 | [![codecov](https://codecov.io/gh/procyon-projects/chrono/branch/main/graph/badge.svg?token=OREV0YI8VU)](https://codecov.io/gh/procyon-projects/chrono) 6 | 7 | Chrono is a scheduler library that lets you run your tasks and code periodically. It provides different scheduling functionalities to make it easier to create a scheduling task. 8 | 9 | ## Scheduling a One-Shot Task 10 | The Schedule method helps us schedule the task to run once at the specified time. In the following example, the task will first be executed 1 second after the current time. 11 | **WithTime** option is used to specify the execution time. 12 | 13 | ```go 14 | taskScheduler := chrono.NewDefaultTaskScheduler() 15 | now := time.Now() 16 | startTime := now.Add(time.Second * 1) 17 | 18 | task, err := taskScheduler.Schedule(func(ctx context.Context) { 19 | log.Print("One-Shot Task") 20 | }, chrono.WithTime(startTime)) 21 | 22 | if err == nil { 23 | log.Print("Task has been scheduled successfully.") 24 | } 25 | ``` 26 | 27 | Also, **WithStartTime** option can be used to specify the execution time. **But It's deprecated.** 28 | 29 | ```go 30 | taskScheduler := chrono.NewDefaultTaskScheduler() 31 | 32 | task, err := taskScheduler.Schedule(func(ctx context.Context) { 33 | log.Print("One-Shot Task") 34 | }, chrono.WithStartTime(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second()+1)) 35 | 36 | if err == nil { 37 | log.Print("Task has been scheduled successfully.") 38 | } 39 | ``` 40 | 41 | ## Scheduling a Task with Fixed Delay 42 | Let's schedule a task to run with a fixed delay between the finish time of the last execution of the task and the start time of the next execution of the task. 43 | The fixed delay counts the delay after the completion of the last execution. 44 | 45 | ```go 46 | taskScheduler := chrono.NewDefaultTaskScheduler() 47 | 48 | task, err := taskScheduler.ScheduleWithFixedDelay(func(ctx context.Context) { 49 | log.Print("Fixed Delay Task") 50 | time.Sleep(3 * time.Second) 51 | }, 5 * time.Second) 52 | 53 | if err == nil { 54 | log.Print("Task has been scheduled successfully.") 55 | } 56 | ``` 57 | 58 | Since the task itself takes 3 seconds to complete and we have specified a delay of 5 seconds between the finish time of the last execution of the task and the start time of the next execution of the task, there will be a delay of 8 seconds between each execution. 59 | 60 | **WithStartTime** and **WithLocation** options can be combined with this. 61 | 62 | ## Schedule Task at a Fixed Rate 63 | Let's schedule a task to run at a fixed rate of seconds. 64 | 65 | ```go 66 | taskScheduler := chrono.NewDefaultTaskScheduler() 67 | 68 | task, err := taskScheduler.ScheduleAtFixedRate(func(ctx context.Context) { 69 | log.Print("Fixed Rate of 5 seconds") 70 | }, 5 * time.Second) 71 | 72 | if err == nil { 73 | log.Print("Task has been scheduled successfully.") 74 | } 75 | ``` 76 | 77 | The next task will run always after 5 seconds no matter the status of the previous task, which may be still running. So even if the previous task isn't done, the next task will run. 78 | We can also use the **WithStartTime** option to specify the desired first execution time of the task. 79 | 80 | ```go 81 | now := time.Now() 82 | 83 | task, err := taskScheduler.ScheduleAtFixedRate(func(ctx context.Context) { 84 | log.Print("Fixed Rate of 5 seconds") 85 | }, 5 * time.Second, chrono.WithStartTime(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second() + 2)) 86 | ``` 87 | 88 | When we use this option, the task will run at the specified execution time and subsequently with the given period. In the above example, the task will first be executed 2 seconds after the current time. 89 | 90 | We can also combine this option with **WithLocation** based on our requirements. 91 | 92 | ```go 93 | now := time.Now() 94 | 95 | task, err := taskScheduler.ScheduleAtFixedRate(func(ctx context.Context) { 96 | log.Print("Fixed Rate of 5 seconds") 97 | }, 5 * time.Second, chrono.WithStartTime(now.Year(), now.Month(), now.Day(), 18, 45, 0), 98 | chrono.WithLocation("America/New_York")) 99 | ``` 100 | 101 | In the above example, the task will first be executed at 18:45 of the current date in America/New York time. 102 | **If the start time is in the past, the task will be executed immediately.** 103 | 104 | ## Scheduling a Task using Cron Expression 105 | Sometimes Fixed Rate and Fixed Delay can not fulfill your needs, and we need the flexibility of cron expressions to schedule the execution of your tasks. With the help of the provided **ScheduleWithCron method**, we can schedule a task based on a cron expression. 106 | 107 | ```go 108 | taskScheduler := chrono.NewDefaultTaskScheduler() 109 | 110 | task, err := taskScheduler.ScheduleWithCron(func(ctx context.Context) { 111 | log.Print("Scheduled Task With Cron") 112 | }, "0 45 18 10 * *") 113 | 114 | if err == nil { 115 | log.Print("Task has been scheduled") 116 | } 117 | ``` 118 | 119 | In this case, we're scheduling a task to be executed at 18:45 on the 10th day of every month 120 | 121 | By default, the local time is used for the cron expression. However, we can use the **WithLocation** option to change this. 122 | 123 | ```go 124 | task, err := taskScheduler.ScheduleWithCron(func(ctx context.Context) { 125 | log.Print("Scheduled Task With Cron") 126 | }, "0 45 18 10 * *", chrono.WithLocation("America/New_York")) 127 | ``` 128 | 129 | In the above example, Task will be scheduled to be executed at 18:45 on the 10th day of every month in America/New York time. 130 | 131 | **WithStartTimeoption** cannot be used with **ScheduleWithCron**. 132 | 133 | ## Canceling a Scheduled Task 134 | Schedule methods return an instance of type ScheduledTask, which allows us to cancel a task or to check if the task is canceled. The Cancel method cancels the scheduled task but running tasks won't be interrupted. 135 | 136 | 137 | ```go 138 | taskScheduler := chrono.NewDefaultTaskScheduler() 139 | 140 | task, err := taskScheduler.ScheduleAtFixedRate(func(ctx context.Context) { 141 | log.Print("Fixed Rate of 5 seconds") 142 | }, 5 * time.Second) 143 | 144 | /* ... */ 145 | 146 | task.Cancel() 147 | ``` 148 | 149 | ## Shutting Down a Scheduler 150 | The **Shutdown()** method doesn't cause immediate shut down of the Scheduler and returns a channel. It will make the Scheduler stop accepting new tasks and shut down after all running tasks finish their current work. 151 | 152 | 153 | ```go 154 | taskScheduler := chrono.NewDefaultTaskScheduler() 155 | 156 | /* ... */ 157 | 158 | shutdownChannel := taskScheduler.Shutdown() 159 | <- shutdownChannel 160 | 161 | /* after all running task finished their works */ 162 | ``` 163 | 164 | Stargazers 165 | ----------- 166 | [![Stargazers repo roster for @procyon-projects/chrono](https://reporoster.com/stars/procyon-projects/chrono)](https://codnect.io/chrono/stargazers) 167 | 168 | Forkers 169 | ----------- 170 | [![Forkers repo roster for @procyon-projects/chrono](https://reporoster.com/forks/procyon-projects/chrono)](https://codnect.io/chrono/network/members) 171 | 172 | # License 173 | Chrono is released under MIT License. 174 | -------------------------------------------------------------------------------- /cron.go: -------------------------------------------------------------------------------- 1 | package chrono 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "math" 7 | "math/bits" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | var ( 14 | months = []string{"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"} 15 | days = []string{"MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"} 16 | ) 17 | 18 | type cronField string 19 | 20 | const ( 21 | cronFieldNanoSecond = "NANO_SECOND" 22 | cronFieldSecond = "SECOND" 23 | cronFieldMinute = "MINUTE" 24 | cronFieldHour = "HOUR" 25 | cronFieldDayOfMonth = "DAY_OF_MONTH" 26 | cronFieldMonth = "MONTH" 27 | cronFieldDayOfWeek = "DAY_OF_WEEK" 28 | ) 29 | 30 | type fieldType struct { 31 | Field cronField 32 | MinValue int 33 | MaxValue int 34 | } 35 | 36 | var ( 37 | nanoSecond = fieldType{cronFieldNanoSecond, 0, 999999999} 38 | second = fieldType{cronFieldSecond, 0, 59} 39 | minute = fieldType{cronFieldMinute, 0, 59} 40 | hour = fieldType{cronFieldHour, 0, 23} 41 | dayOfMonth = fieldType{cronFieldDayOfMonth, 1, 31} 42 | month = fieldType{cronFieldMonth, 1, 12} 43 | dayOfWeek = fieldType{cronFieldDayOfWeek, 1, 7} 44 | ) 45 | 46 | var cronFieldTypes = []fieldType{ 47 | second, 48 | minute, 49 | hour, 50 | dayOfMonth, 51 | month, 52 | dayOfWeek, 53 | } 54 | 55 | type valueRange struct { 56 | MinValue int 57 | MaxValue int 58 | } 59 | 60 | func newValueRange(min int, max int) valueRange { 61 | return valueRange{ 62 | MinValue: min, 63 | MaxValue: max, 64 | } 65 | } 66 | 67 | type cronFieldBits struct { 68 | Typ fieldType 69 | Bits uint64 70 | } 71 | 72 | func newFieldBits(typ fieldType) *cronFieldBits { 73 | return &cronFieldBits{ 74 | Typ: typ, 75 | } 76 | } 77 | 78 | const maxAttempts = 366 79 | const mask = 0xFFFFFFFFFFFFFFFF 80 | 81 | type CronExpression struct { 82 | fields []*cronFieldBits 83 | } 84 | 85 | func newCronExpression() *CronExpression { 86 | exp := &CronExpression{ 87 | make([]*cronFieldBits, 0), 88 | } 89 | 90 | nanoSecondBits := newFieldBits(nanoSecond) 91 | nanoSecondBits.Bits = 1 92 | 93 | exp.fields = append(exp.fields, nanoSecondBits) 94 | return exp 95 | } 96 | 97 | func (expression *CronExpression) NextTime(t time.Time) time.Time { 98 | 99 | t = t.Add(1 * time.Nanosecond) 100 | 101 | for i := 0; i < maxAttempts; i++ { 102 | result := expression.next(t) 103 | 104 | if result.IsZero() || result.Equal(t) { 105 | return result 106 | } 107 | 108 | t = result 109 | } 110 | 111 | return time.Time{} 112 | } 113 | 114 | func (expression *CronExpression) next(t time.Time) time.Time { 115 | for _, field := range expression.fields { 116 | t = expression.nextField(field, t) 117 | 118 | if t.IsZero() { 119 | return t 120 | } 121 | } 122 | 123 | return t 124 | } 125 | 126 | func (expression *CronExpression) nextField(field *cronFieldBits, t time.Time) time.Time { 127 | current := getTimeValue(t, field.Typ.Field) 128 | next := setNextBit(field.Bits, current) 129 | 130 | if next == -1 { 131 | amount := getFieldMaxValue(t, field.Typ) - current + 1 132 | t = addTime(t, field.Typ.Field, amount) 133 | next = setNextBit(field.Bits, 0) 134 | } 135 | 136 | if next == current { 137 | return t 138 | } else { 139 | count := 0 140 | current := getTimeValue(t, field.Typ.Field) 141 | for ; current != next && count < maxAttempts; count++ { 142 | t = elapseUntil(t, field.Typ, next) 143 | current = getTimeValue(t, field.Typ.Field) 144 | } 145 | 146 | if count >= maxAttempts { 147 | return time.Time{} 148 | } 149 | 150 | return t 151 | } 152 | } 153 | 154 | func ParseCronExpression(expression string) (*CronExpression, error) { 155 | if len(expression) == 0 { 156 | return nil, errors.New("cron expression must not be empty") 157 | } 158 | 159 | fields := strings.Fields(expression) 160 | 161 | if len(fields) != 6 { 162 | return nil, fmt.Errorf("cron expression must consist of 6 fields : found %d in \"%s\"", len(fields), expression) 163 | } 164 | 165 | cronExpression := newCronExpression() 166 | 167 | for index, cronFieldType := range cronFieldTypes { 168 | value, err := parseField(fields[index], cronFieldType) 169 | 170 | if err != nil { 171 | return nil, err 172 | } 173 | 174 | if cronFieldType.Field == cronFieldDayOfWeek && value.Bits&1<<0 != 0 { 175 | value.Bits |= 1 << 7 176 | temp := ^(1 << 0) 177 | value.Bits &= uint64(temp) 178 | } 179 | 180 | cronExpression.fields = append(cronExpression.fields, value) 181 | } 182 | 183 | return cronExpression, nil 184 | } 185 | 186 | func parseField(value string, fieldType fieldType) (*cronFieldBits, error) { 187 | if len(value) == 0 { 188 | return nil, fmt.Errorf("value must not be empty") 189 | } 190 | 191 | if fieldType.Field == cronFieldMonth { 192 | value = replaceOrdinals(value, months) 193 | } else if fieldType.Field == cronFieldDayOfWeek { 194 | value = replaceOrdinals(value, days) 195 | } 196 | 197 | cronFieldBits := newFieldBits(fieldType) 198 | 199 | fields := strings.Split(value, ",") 200 | 201 | for _, field := range fields { 202 | slashPos := strings.Index(field, "/") 203 | 204 | step := -1 205 | var valueRange valueRange 206 | 207 | if slashPos != -1 { 208 | rangeStr := field[0:slashPos] 209 | 210 | var err error 211 | valueRange, err = parseRange(rangeStr, fieldType) 212 | 213 | if err != nil { 214 | return nil, err 215 | } 216 | 217 | if strings.Index(rangeStr, "-") == -1 { 218 | valueRange = newValueRange(valueRange.MinValue, fieldType.MaxValue) 219 | } 220 | 221 | stepStr := field[slashPos+1:] 222 | 223 | step, err = strconv.Atoi(stepStr) 224 | 225 | if err != nil { 226 | return nil, fmt.Errorf("step must be number : \"%s\"", stepStr) 227 | } 228 | 229 | if step <= 0 { 230 | return nil, fmt.Errorf("step must be 1 or higher in \"%s\"", value) 231 | } 232 | 233 | } else { 234 | var err error 235 | valueRange, err = parseRange(field, fieldType) 236 | 237 | if err != nil { 238 | return nil, err 239 | } 240 | } 241 | 242 | if step > 1 { 243 | for index := valueRange.MinValue; index <= valueRange.MaxValue; index += step { 244 | cronFieldBits.Bits |= 1 << index 245 | } 246 | continue 247 | } 248 | 249 | if valueRange.MinValue == valueRange.MaxValue { 250 | cronFieldBits.Bits |= 1 << valueRange.MinValue 251 | } else { 252 | cronFieldBits.Bits |= ^(math.MaxUint64 << (valueRange.MaxValue + 1)) & (math.MaxUint64 << valueRange.MinValue) 253 | } 254 | } 255 | 256 | return cronFieldBits, nil 257 | } 258 | 259 | func parseRange(value string, fieldType fieldType) (valueRange, error) { 260 | if value == "*" { 261 | return newValueRange(fieldType.MinValue, fieldType.MaxValue), nil 262 | } else { 263 | hyphenPos := strings.Index(value, "-") 264 | 265 | if hyphenPos == -1 { 266 | result, err := checkValidValue(value, fieldType) 267 | 268 | if err != nil { 269 | return valueRange{}, err 270 | } 271 | 272 | return newValueRange(result, result), nil 273 | } else { 274 | maxStr := value[hyphenPos+1:] 275 | minStr := value[0:hyphenPos] 276 | 277 | min, err := checkValidValue(minStr, fieldType) 278 | 279 | if err != nil { 280 | return valueRange{}, err 281 | } 282 | var max int 283 | max, err = checkValidValue(maxStr, fieldType) 284 | 285 | if err != nil { 286 | return valueRange{}, err 287 | } 288 | 289 | if fieldType.Field == cronFieldDayOfWeek && min == 7 { 290 | min = 0 291 | } 292 | 293 | return newValueRange(min, max), nil 294 | } 295 | } 296 | } 297 | 298 | func replaceOrdinals(value string, list []string) string { 299 | value = strings.ToUpper(value) 300 | 301 | for index := 0; index < len(list); index++ { 302 | replacement := strconv.Itoa(index + 1) 303 | value = strings.ReplaceAll(value, list[index], replacement) 304 | } 305 | 306 | return value 307 | } 308 | 309 | func checkValidValue(value string, fieldType fieldType) (int, error) { 310 | result, err := strconv.Atoi(value) 311 | 312 | if err != nil { 313 | return 0, fmt.Errorf("the value in field %s must be number : %s", fieldType.Field, value) 314 | } 315 | 316 | if fieldType.Field == cronFieldDayOfWeek && result == 0 { 317 | return result, nil 318 | } 319 | 320 | if result >= fieldType.MinValue && result <= fieldType.MaxValue { 321 | return result, nil 322 | } 323 | 324 | return 0, fmt.Errorf("the value in field %s must be between %d and %d", fieldType.Field, fieldType.MinValue, fieldType.MaxValue) 325 | } 326 | 327 | func getTimeValue(t time.Time, field cronField) int { 328 | 329 | switch field { 330 | case cronFieldNanoSecond: 331 | return t.Nanosecond() 332 | case cronFieldSecond: 333 | return t.Second() 334 | case cronFieldMinute: 335 | return t.Minute() 336 | case cronFieldHour: 337 | return t.Hour() 338 | case cronFieldDayOfMonth: 339 | return t.Day() 340 | case cronFieldMonth: 341 | return int(t.Month()) 342 | case cronFieldDayOfWeek: 343 | if t.Weekday() == 0 { 344 | return 7 345 | } 346 | return int(t.Weekday()) 347 | } 348 | 349 | panic("unreachable code!") 350 | } 351 | 352 | func addTime(t time.Time, field cronField, value int) time.Time { 353 | switch field { 354 | case cronFieldNanoSecond: 355 | return t.Add(time.Duration(value) * time.Nanosecond) 356 | case cronFieldSecond: 357 | return t.Add(time.Duration(value) * time.Second) 358 | case cronFieldMinute: 359 | return t.Add(time.Duration(value) * time.Minute) 360 | case cronFieldHour: 361 | return t.Add(time.Duration(value) * time.Hour) 362 | case cronFieldDayOfMonth: 363 | return t.AddDate(0, 0, value) 364 | case cronFieldMonth: 365 | return t.AddDate(0, value, 0) 366 | case cronFieldDayOfWeek: 367 | return t.AddDate(0, 0, value) 368 | } 369 | 370 | panic("unreachable code!") 371 | } 372 | 373 | func setNextBit(bitsValue uint64, index int) int { 374 | result := bitsValue & (mask << index) 375 | 376 | if result != 0 { 377 | return bits.TrailingZeros64(result) 378 | } 379 | 380 | return -1 381 | } 382 | 383 | func elapseUntil(t time.Time, fieldType fieldType, value int) time.Time { 384 | current := getTimeValue(t, fieldType.Field) 385 | 386 | maxValue := getFieldMaxValue(t, fieldType) 387 | 388 | if current >= value { 389 | amount := value + maxValue - current + 1 - fieldType.MinValue 390 | return addTime(t, fieldType.Field, amount) 391 | } 392 | 393 | if value >= fieldType.MinValue && value <= maxValue { 394 | return with(t, fieldType.Field, value) 395 | } 396 | 397 | return addTime(t, fieldType.Field, value-current) 398 | } 399 | 400 | func with(t time.Time, field cronField, value int) time.Time { 401 | switch field { 402 | case cronFieldNanoSecond: 403 | return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), value, time.Local) 404 | case cronFieldSecond: 405 | return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), value, t.Nanosecond(), time.Local) 406 | case cronFieldMinute: 407 | return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), value, t.Second(), t.Nanosecond(), time.Local) 408 | case cronFieldHour: 409 | return time.Date(t.Year(), t.Month(), t.Day(), value, t.Minute(), t.Second(), t.Nanosecond(), time.Local) 410 | case cronFieldDayOfMonth: 411 | return time.Date(t.Year(), t.Month(), value, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.Local) 412 | case cronFieldMonth: 413 | return time.Date(t.Year(), time.Month(value), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.Local) 414 | case cronFieldDayOfWeek: 415 | return t.AddDate(0, 0, value-int(t.Weekday())) 416 | } 417 | 418 | panic("unreachable code!") 419 | } 420 | 421 | func getFieldMaxValue(t time.Time, fieldType fieldType) int { 422 | 423 | if cronFieldDayOfMonth == fieldType.Field { 424 | switch int(t.Month()) { 425 | case 2: 426 | if isLeapYear(t.Year()) { 427 | return 29 428 | } 429 | return 28 430 | case 4: 431 | return 30 432 | case 6: 433 | return 30 434 | case 9: 435 | return 30 436 | case 11: 437 | return 30 438 | default: 439 | return 31 440 | } 441 | } 442 | 443 | return fieldType.MaxValue 444 | } 445 | 446 | func isLeapYear(year int) bool { 447 | return year%400 == 0 || year%100 != 0 && year%4 == 0 448 | } 449 | -------------------------------------------------------------------------------- /cron_test.go: -------------------------------------------------------------------------------- 1 | package chrono 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | const timeLayout = "2006-01-02 15:04:05" 10 | 11 | func TestCronExpression_NextTime(t *testing.T) { 12 | testCases := []struct { 13 | expression string 14 | time string 15 | nextTimes []string 16 | }{ 17 | { 18 | "* * * * * *", 19 | "2021-05-31 23:59:56", 20 | []string{ 21 | "2021-05-31 23:59:57", 22 | "2021-05-31 23:59:58", 23 | "2021-05-31 23:59:59", 24 | "2021-06-01 00:00:00", 25 | "2021-06-01 00:00:01", 26 | "2021-06-01 00:00:02", 27 | }, 28 | }, 29 | { 30 | "17/3 * * * * *", 31 | "2021-03-16 15:04:16", 32 | []string{ 33 | "2021-03-16 15:04:17", 34 | "2021-03-16 15:04:20", 35 | "2021-03-16 15:04:23", 36 | "2021-03-16 15:04:26", 37 | "2021-03-16 15:04:29", 38 | "2021-03-16 15:04:32", 39 | }, 40 | }, 41 | { 42 | "19/3 * * * * *", 43 | "2021-03-16 15:04:19", 44 | []string{ 45 | "2021-03-16 15:04:22", 46 | "2021-03-16 15:04:25", 47 | "2021-03-16 15:04:28", 48 | "2021-03-16 15:04:31", 49 | "2021-03-16 15:04:34", 50 | "2021-03-16 15:04:37", 51 | }, 52 | }, 53 | { 54 | "8-19/3 * * * * *", 55 | "2021-03-16 15:04:23", 56 | []string{ 57 | "2021-03-16 15:05:08", 58 | "2021-03-16 15:05:11", 59 | "2021-03-16 15:05:14", 60 | "2021-03-16 15:05:17", 61 | "2021-03-16 15:06:08", 62 | "2021-03-16 15:06:11", 63 | }, 64 | }, 65 | { 66 | "8-24 * * * * *", 67 | "2021-03-16 15:04:23", 68 | []string{ 69 | "2021-03-16 15:04:24", 70 | "2021-03-16 15:05:08", 71 | "2021-03-16 15:05:09", 72 | "2021-03-16 15:05:10", 73 | "2021-03-16 15:05:11", 74 | "2021-03-16 15:05:12", 75 | }, 76 | }, 77 | { 78 | "0 * * * * *", 79 | "2021-05-21 13:41:37", 80 | []string{ 81 | "2021-05-21 13:42:00", 82 | "2021-05-21 13:43:00", 83 | "2021-05-21 13:44:00", 84 | "2021-05-21 13:45:00", 85 | "2021-05-21 13:46:00", 86 | "2021-05-21 13:47:00", 87 | }, 88 | }, 89 | { 90 | "7 * * * * *", 91 | "2021-05-22 13:12:56", 92 | []string{ 93 | "2021-05-22 13:13:07", 94 | "2021-05-22 13:14:07", 95 | "2021-05-22 13:15:07", 96 | "2021-05-22 13:16:07", 97 | "2021-05-22 13:17:07", 98 | "2021-05-22 13:18:07", 99 | }, 100 | }, 101 | { 102 | "0 0 * * * *", 103 | "2021-05-21 13:41:37", 104 | []string{ 105 | "2021-05-21 14:00:00", 106 | "2021-05-21 15:00:00", 107 | "2021-05-21 16:00:00", 108 | "2021-05-21 17:00:00", 109 | "2021-05-21 18:00:00", 110 | "2021-05-21 19:00:00", 111 | }, 112 | }, 113 | { 114 | "18 15 * * * *", 115 | "2021-05-21 19:12:56", 116 | []string{ 117 | "2021-05-21 19:15:18", 118 | "2021-05-21 20:15:18", 119 | "2021-05-21 21:15:18", 120 | "2021-05-21 22:15:18", 121 | "2021-05-21 23:15:18", 122 | "2021-05-22 00:15:18", 123 | }, 124 | }, 125 | { 126 | "18 15/5 * * * *", 127 | "2021-05-21 19:43:56", 128 | []string{ 129 | "2021-05-21 19:45:18", 130 | "2021-05-21 19:50:18", 131 | "2021-05-21 19:55:18", 132 | "2021-05-21 20:15:18", 133 | "2021-05-21 20:20:18", 134 | "2021-05-21 20:25:18", 135 | }, 136 | }, 137 | { 138 | "18 15-30/5 * * * *", 139 | "2021-05-21 19:43:56", 140 | []string{ 141 | "2021-05-21 20:15:18", 142 | "2021-05-21 20:20:18", 143 | "2021-05-21 20:25:18", 144 | "2021-05-21 20:30:18", 145 | "2021-05-21 21:15:18", 146 | "2021-05-21 21:20:18", 147 | }, 148 | }, 149 | { 150 | "18 40-45 * * * *", 151 | "2021-05-21 19:43:56", 152 | []string{ 153 | "2021-05-21 19:44:18", 154 | "2021-05-21 19:45:18", 155 | "2021-05-21 20:40:18", 156 | "2021-05-21 20:41:18", 157 | "2021-05-21 20:42:18", 158 | "2021-05-21 20:43:18", 159 | }, 160 | }, 161 | { 162 | "0 0 0 * * *", 163 | "2020-02-27 13:41:37", 164 | []string{ 165 | "2020-02-28 00:00:00", 166 | "2020-02-29 00:00:00", 167 | "2020-03-01 00:00:00", 168 | "2020-03-02 00:00:00", 169 | "2020-03-03 00:00:00", 170 | "2020-03-04 00:00:00", 171 | }, 172 | }, 173 | { 174 | "45 13 14 * * *", 175 | "2020-12-28 13:41:37", 176 | []string{ 177 | "2020-12-28 14:13:45", 178 | "2020-12-29 14:13:45", 179 | "2020-12-30 14:13:45", 180 | "2020-12-31 14:13:45", 181 | "2021-01-01 14:13:45", 182 | "2021-01-02 14:13:45", 183 | }, 184 | }, 185 | { 186 | "45 13 14/3 * * *", 187 | "2020-12-28 13:41:37", 188 | []string{ 189 | "2020-12-28 14:13:45", 190 | "2020-12-28 17:13:45", 191 | "2020-12-28 20:13:45", 192 | "2020-12-28 23:13:45", 193 | "2020-12-29 14:13:45", 194 | "2020-12-29 17:13:45", 195 | }, 196 | }, 197 | { 198 | "45 13 9-16/3 * * *", 199 | "2020-12-28 13:41:37", 200 | []string{ 201 | "2020-12-28 15:13:45", 202 | "2020-12-29 09:13:45", 203 | "2020-12-29 12:13:45", 204 | "2020-12-29 15:13:45", 205 | "2020-12-30 09:13:45", 206 | "2020-12-30 12:13:45", 207 | }, 208 | }, 209 | { 210 | "45 13 9-16 * * *", 211 | "2020-12-28 13:41:37", 212 | []string{ 213 | "2020-12-28 14:13:45", 214 | "2020-12-28 15:13:45", 215 | "2020-12-28 16:13:45", 216 | "2020-12-29 09:13:45", 217 | "2020-12-29 10:13:45", 218 | "2020-12-29 11:13:45", 219 | }, 220 | }, 221 | { 222 | "20 45 18 6 * *", 223 | "2020-03-27 13:41:37", 224 | []string{ 225 | "2020-04-06 18:45:20", 226 | "2020-05-06 18:45:20", 227 | "2020-06-06 18:45:20", 228 | "2020-07-06 18:45:20", 229 | "2020-08-06 18:45:20", 230 | "2020-09-06 18:45:20", 231 | }, 232 | }, 233 | { 234 | "20 45 18 10-12 * *", 235 | "2020-03-27 13:41:37", 236 | []string{ 237 | "2020-04-10 18:45:20", 238 | "2020-04-11 18:45:20", 239 | "2020-04-12 18:45:20", 240 | "2020-05-10 18:45:20", 241 | "2020-05-11 18:45:20", 242 | "2020-05-12 18:45:20", 243 | }, 244 | }, 245 | { 246 | "20 45 18 5-20/3 * *", 247 | "2020-03-27 13:41:37", 248 | []string{ 249 | "2020-04-05 18:45:20", 250 | "2020-04-08 18:45:20", 251 | "2020-04-11 18:45:20", 252 | "2020-04-14 18:45:20", 253 | "2020-04-17 18:45:20", 254 | "2020-04-20 18:45:20", 255 | }, 256 | }, 257 | { 258 | "0 0 0 1 * *", 259 | "2020-03-27 13:41:37", 260 | []string{ 261 | "2020-04-01 00:00:00", 262 | "2020-05-01 00:00:00", 263 | "2020-06-01 00:00:00", 264 | "2020-07-01 00:00:00", 265 | "2020-08-01 00:00:00", 266 | "2020-09-01 00:00:00", 267 | }, 268 | }, 269 | { 270 | "0 0 0 1 1 *", 271 | "2020-03-27 13:41:37", 272 | []string{ 273 | "2021-01-01 00:00:00", 274 | "2022-01-01 00:00:00", 275 | "2023-01-01 00:00:00", 276 | "2024-01-01 00:00:00", 277 | "2025-01-01 00:00:00", 278 | "2026-01-01 00:00:00", 279 | }, 280 | }, 281 | { 282 | "0 0 0 1 6 *", 283 | "2020-03-27 13:41:37", 284 | []string{ 285 | "2020-06-01 00:00:00", 286 | "2021-06-01 00:00:00", 287 | "2022-06-01 00:00:00", 288 | "2023-06-01 00:00:00", 289 | "2024-06-01 00:00:00", 290 | "2025-06-01 00:00:00", 291 | }, 292 | }, 293 | { 294 | "0 0 0 1 3-12 *", 295 | "2020-03-27 13:41:37", 296 | []string{ 297 | "2020-04-01 00:00:00", 298 | "2020-05-01 00:00:00", 299 | "2020-06-01 00:00:00", 300 | "2020-07-01 00:00:00", 301 | "2020-08-01 00:00:00", 302 | "2020-09-01 00:00:00", 303 | }, 304 | }, 305 | { 306 | "0 0 0 1 3-12/3 *", 307 | "2020-03-27 13:41:37", 308 | []string{ 309 | "2020-06-01 00:00:00", 310 | "2020-09-01 00:00:00", 311 | "2020-12-01 00:00:00", 312 | "2021-03-01 00:00:00", 313 | "2021-06-01 00:00:00", 314 | "2021-09-01 00:00:00", 315 | }, 316 | }, 317 | { 318 | "0 0 0 1 SEP *", 319 | "2020-03-27 13:41:37", 320 | []string{ 321 | "2020-09-01 00:00:00", 322 | "2021-09-01 00:00:00", 323 | "2022-09-01 00:00:00", 324 | "2023-09-01 00:00:00", 325 | "2024-09-01 00:00:00", 326 | "2025-09-01 00:00:00", 327 | }, 328 | }, 329 | { 330 | "0 0 0 1 AUG-OCT *", 331 | "2020-03-27 13:41:37", 332 | []string{ 333 | "2020-08-01 00:00:00", 334 | "2020-09-01 00:00:00", 335 | "2020-10-01 00:00:00", 336 | "2021-08-01 00:00:00", 337 | "2021-09-01 00:00:00", 338 | "2021-10-01 00:00:00", 339 | }, 340 | }, 341 | { 342 | "0 0 0 1 5 0", 343 | "2021-05-23 13:41:37", 344 | []string{ 345 | "2022-05-01 00:00:00", 346 | "2033-05-01 00:00:00", 347 | "2039-05-01 00:00:00", 348 | "2044-05-01 00:00:00", 349 | "2050-05-01 00:00:00", 350 | "2061-05-01 00:00:00", 351 | }, 352 | }, 353 | { 354 | "0 0 0 1 5 0", 355 | "2021-05-23 13:41:37", 356 | []string{ 357 | "2022-05-01 00:00:00", 358 | "2033-05-01 00:00:00", 359 | "2039-05-01 00:00:00", 360 | "2044-05-01 00:00:00", 361 | "2050-05-01 00:00:00", 362 | "2061-05-01 00:00:00", 363 | }, 364 | }, 365 | { 366 | "0 0 0 1 5 SUN", 367 | "2021-05-23 13:41:37", 368 | []string{ 369 | "2022-05-01 00:00:00", 370 | "2033-05-01 00:00:00", 371 | "2039-05-01 00:00:00", 372 | "2044-05-01 00:00:00", 373 | "2050-05-01 00:00:00", 374 | "2061-05-01 00:00:00", 375 | }, 376 | }, 377 | { 378 | "0 0 0 1 5 MON", 379 | "2021-05-23 13:41:37", 380 | []string{ 381 | "2023-05-01 00:00:00", 382 | "2028-05-01 00:00:00", 383 | "2034-05-01 00:00:00", 384 | "2045-05-01 00:00:00", 385 | "2051-05-01 00:00:00", 386 | "2056-05-01 00:00:00", 387 | }, 388 | }, 389 | { 390 | "12 15 13 * * THU-SAT", 391 | "2021-05-23 13:41:37", 392 | []string{ 393 | "2021-05-27 13:15:12", 394 | "2021-05-28 13:15:12", 395 | "2021-05-29 13:15:12", 396 | "2021-06-03 13:15:12", 397 | "2021-06-04 13:15:12", 398 | "2021-06-05 13:15:12", 399 | }, 400 | }, 401 | { 402 | "12 15 13 * * 4-6", 403 | "2021-05-23 13:41:37", 404 | []string{ 405 | "2021-05-27 13:15:12", 406 | "2021-05-28 13:15:12", 407 | "2021-05-29 13:15:12", 408 | "2021-06-03 13:15:12", 409 | "2021-06-04 13:15:12", 410 | "2021-06-05 13:15:12", 411 | }, 412 | }, 413 | { 414 | "13-15,46-49 * * * * *", 415 | "2021-05-21 13:18:14", 416 | []string{ 417 | "2021-05-21 13:18:15", 418 | "2021-05-21 13:18:46", 419 | "2021-05-21 13:18:47", 420 | "2021-05-21 13:18:48", 421 | "2021-05-21 13:18:49", 422 | "2021-05-21 13:19:13", 423 | }, 424 | }, 425 | { 426 | "17-31/5,50-57/4 * * * * *", 427 | "2021-05-21 13:18:14", 428 | []string{ 429 | "2021-05-21 13:18:17", 430 | "2021-05-21 13:18:22", 431 | "2021-05-21 13:18:27", 432 | "2021-05-21 13:18:50", 433 | "2021-05-21 13:18:54", 434 | "2021-05-21 13:19:17", 435 | }, 436 | }, 437 | { 438 | "17 7-9,54-55 * * * *", 439 | "2021-05-21 13:02:17", 440 | []string{ 441 | "2021-05-21 13:07:17", 442 | "2021-05-21 13:08:17", 443 | "2021-05-21 13:09:17", 444 | "2021-05-21 13:54:17", 445 | "2021-05-21 13:55:17", 446 | "2021-05-21 14:07:17", 447 | }, 448 | }, 449 | { 450 | "17 8-16/4,50-55/3 * * * *", 451 | "2021-05-21 13:02:17", 452 | []string{ 453 | "2021-05-21 13:08:17", 454 | "2021-05-21 13:12:17", 455 | "2021-05-21 13:16:17", 456 | "2021-05-21 13:50:17", 457 | "2021-05-21 13:53:17", 458 | "2021-05-21 14:08:17", 459 | }, 460 | }, 461 | { 462 | "17 4 5-9,17-19 * * *", 463 | "2021-05-21 08:02:17", 464 | []string{ 465 | "2021-05-21 08:04:17", 466 | "2021-05-21 09:04:17", 467 | "2021-05-21 17:04:17", 468 | "2021-05-21 18:04:17", 469 | "2021-05-21 19:04:17", 470 | "2021-05-22 05:04:17", 471 | }, 472 | }, 473 | { 474 | "17 4 5-9/2,16-23/3 * * *", 475 | "2021-05-21 08:02:17", 476 | []string{ 477 | "2021-05-21 09:04:17", 478 | "2021-05-21 16:04:17", 479 | "2021-05-21 19:04:17", 480 | "2021-05-21 22:04:17", 481 | "2021-05-22 05:04:17", 482 | "2021-05-22 07:04:17", 483 | }, 484 | }, 485 | { 486 | "17 4 17 13-15,26-27 * *", 487 | "2021-05-14 08:02:17", 488 | []string{ 489 | "2021-05-14 17:04:17", 490 | "2021-05-15 17:04:17", 491 | "2021-05-26 17:04:17", 492 | "2021-05-27 17:04:17", 493 | "2021-06-13 17:04:17", 494 | "2021-06-14 17:04:17", 495 | }, 496 | }, 497 | { 498 | "17 4 17 7-15/4,22-29/3 * *", 499 | "2021-05-13 08:02:17", 500 | []string{ 501 | "2021-05-15 17:04:17", 502 | "2021-05-22 17:04:17", 503 | "2021-05-25 17:04:17", 504 | "2021-05-28 17:04:17", 505 | "2021-06-07 17:04:17", 506 | "2021-06-11 17:04:17", 507 | }, 508 | }, 509 | { 510 | "17 4 17 16 1-3,11-12 *", 511 | "2021-02-13 08:02:17", 512 | []string{ 513 | "2021-02-16 17:04:17", 514 | "2021-03-16 17:04:17", 515 | "2021-11-16 17:04:17", 516 | "2021-12-16 17:04:17", 517 | "2022-01-16 17:04:17", 518 | "2022-02-16 17:04:17", 519 | }, 520 | }, 521 | { 522 | "17 4 17 16 JAN-MAR,NOV-DEC *", 523 | "2021-02-13 08:02:17", 524 | []string{ 525 | "2021-02-16 17:04:17", 526 | "2021-03-16 17:04:17", 527 | "2021-11-16 17:04:17", 528 | "2021-12-16 17:04:17", 529 | "2022-01-16 17:04:17", 530 | "2022-02-16 17:04:17", 531 | }, 532 | }, 533 | { 534 | "17 4 17 16 4-10/3,8-12/2 *", 535 | "2021-02-13 08:02:17", 536 | []string{ 537 | "2021-04-16 17:04:17", 538 | "2021-07-16 17:04:17", 539 | "2021-08-16 17:04:17", 540 | "2021-10-16 17:04:17", 541 | "2021-12-16 17:04:17", 542 | "2022-04-16 17:04:17", 543 | }, 544 | }, 545 | { 546 | "17 4 17 16 APR-OCT/3,AUG-DEC/2 *", 547 | "2021-02-13 08:02:17", 548 | []string{ 549 | "2021-04-16 17:04:17", 550 | "2021-07-16 17:04:17", 551 | "2021-08-16 17:04:17", 552 | "2021-10-16 17:04:17", 553 | "2021-12-16 17:04:17", 554 | "2022-04-16 17:04:17", 555 | }, 556 | }, 557 | { 558 | "17 4 17 16 5 MON-SUN/3", 559 | "2021-02-13 08:02:17", 560 | []string{ 561 | "2021-05-16 17:04:17", 562 | "2022-05-16 17:04:17", 563 | "2024-05-16 17:04:17", 564 | "2027-05-16 17:04:17", 565 | "2030-05-16 17:04:17", 566 | "2032-05-16 17:04:17", 567 | }, 568 | }, 569 | { 570 | "17 4 17 16 5 MON-TUE,FRI-SAT", 571 | "2021-02-13 08:02:17", 572 | []string{ 573 | "2022-05-16 17:04:17", 574 | "2023-05-16 17:04:17", 575 | "2025-05-16 17:04:17", 576 | "2026-05-16 17:04:17", 577 | "2028-05-16 17:04:17", 578 | "2031-05-16 17:04:17", 579 | }, 580 | }, 581 | { 582 | "17 4 17 16 5 MON-THU/2,FRI-SUN/2", 583 | "2021-02-13 08:02:17", 584 | []string{ 585 | "2021-05-16 17:04:17", 586 | "2022-05-16 17:04:17", 587 | "2025-05-16 17:04:17", 588 | "2027-05-16 17:04:17", 589 | "2029-05-16 17:04:17", 590 | "2031-05-16 17:04:17", 591 | }, 592 | }, 593 | } 594 | 595 | for _, testCase := range testCases { 596 | exp, err := ParseCronExpression(testCase.expression) 597 | 598 | if err != nil { 599 | t.Errorf("could not parse cron expression : %s", err.Error()) 600 | return 601 | } 602 | 603 | date, err := time.Parse(timeLayout, testCase.time) 604 | 605 | if err != nil { 606 | t.Errorf("could not parse time : %s", testCase.time) 607 | return 608 | } 609 | 610 | for _, nextTimeStr := range testCase.nextTimes { 611 | nextTime, err := time.Parse(timeLayout, nextTimeStr) 612 | 613 | if err != nil { 614 | t.Errorf("could not parse next time : %s", nextTimeStr) 615 | return 616 | } 617 | 618 | date = exp.NextTime(date) 619 | 620 | if nextTime.Format(timeLayout) != date.Format(timeLayout) { 621 | t.Errorf("got: %s expected: %s", date, nextTime) 622 | } 623 | } 624 | } 625 | 626 | } 627 | 628 | func TestParseCronExpression_Errors(t *testing.T) { 629 | testCases := []struct { 630 | expression string 631 | errorString string 632 | }{ 633 | {expression: "", errorString: "cron expression must not be empty"}, 634 | {expression: "test * * * * *", errorString: "the value in field SECOND must be number : test"}, 635 | {expression: "5 * * * *", errorString: "cron expression must consist of 6 fields : found 5 in \"5 * * * *\""}, 636 | {expression: "61 * * * * *", errorString: "the value in field SECOND must be between 0 and 59"}, 637 | {expression: "61 * * * * *", errorString: "the value in field SECOND must be between 0 and 59"}, 638 | {expression: "* 65 * * * *", errorString: "the value in field MINUTE must be between 0 and 59"}, 639 | {expression: "* * * 0 * *", errorString: "the value in field DAY_OF_MONTH must be between 1 and 31"}, 640 | {expression: "* * 1-12/0 * * *", errorString: "step must be 1 or higher in \"1-12/0\""}, 641 | {expression: "* * 0-32/5 * * *", errorString: "the value in field HOUR must be between 0 and 23"}, 642 | {expression: "* * * * 0-10/2 *", errorString: "the value in field MONTH must be between 1 and 12"}, 643 | {expression: "* * 1-12/test * * *", errorString: "step must be number : \"test\""}, 644 | } 645 | 646 | for _, testCase := range testCases { 647 | exp, err := ParseCronExpression(testCase.expression) 648 | assert.Nil(t, exp, "expression must have been parsed : %s", testCase.expression) 649 | assert.NotNil(t, err, "an error must have been occurred") 650 | assert.Equal(t, testCase.errorString, err.Error(), 651 | "error string must not match, expected : %s, actual :%s", testCase.errorString, err.Error()) 652 | } 653 | } 654 | 655 | func TestParseField_WhenValueIsEmpty(t *testing.T) { 656 | result, err := parseField("", second) 657 | assert.Nil(t, result, "result must not have been returned") 658 | assert.NotNil(t, err, "an error must have been occurred") 659 | assert.Equal(t, "value must not be empty", err.Error()) 660 | } 661 | -------------------------------------------------------------------------------- /executor.go: -------------------------------------------------------------------------------- 1 | package chrono 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | type TaskExecutor interface { 11 | Schedule(task Task, delay time.Duration) (ScheduledTask, error) 12 | ScheduleWithFixedDelay(task Task, initialDelay time.Duration, delay time.Duration) (ScheduledTask, error) 13 | ScheduleAtFixedRate(task Task, initialDelay time.Duration, period time.Duration) (ScheduledTask, error) 14 | IsShutdown() bool 15 | Shutdown() chan bool 16 | } 17 | 18 | type SimpleTaskExecutor struct { 19 | nextSequence int 20 | isShutdown bool 21 | executorMu sync.RWMutex 22 | timer *time.Timer 23 | taskWaitGroup sync.WaitGroup 24 | taskQueue ScheduledTaskQueue 25 | newTaskChannel chan *ScheduledRunnableTask 26 | rescheduleTaskChannel chan *ScheduledRunnableTask 27 | taskRunner TaskRunner 28 | shutdownChannel chan chan bool 29 | } 30 | 31 | func NewDefaultTaskExecutor() TaskExecutor { 32 | return NewSimpleTaskExecutor(NewDefaultTaskRunner()) 33 | } 34 | 35 | func NewSimpleTaskExecutor(runner TaskRunner) *SimpleTaskExecutor { 36 | if runner == nil { 37 | runner = NewDefaultTaskRunner() 38 | } 39 | 40 | executor := &SimpleTaskExecutor{ 41 | timer: time.NewTimer(1 * time.Hour), 42 | taskQueue: make(ScheduledTaskQueue, 0), 43 | newTaskChannel: make(chan *ScheduledRunnableTask), 44 | rescheduleTaskChannel: make(chan *ScheduledRunnableTask), 45 | taskRunner: runner, 46 | shutdownChannel: make(chan chan bool), 47 | } 48 | 49 | executor.timer.Stop() 50 | 51 | go executor.run() 52 | 53 | return executor 54 | } 55 | 56 | func (executor *SimpleTaskExecutor) Schedule(task Task, delay time.Duration) (ScheduledTask, error) { 57 | if task == nil { 58 | return nil, errors.New("task cannot be nil") 59 | } 60 | 61 | executor.executorMu.Lock() 62 | 63 | if executor.isShutdown { 64 | executor.executorMu.Unlock() 65 | return nil, errors.New("no new task won't be accepted because executor is already shut down") 66 | } 67 | 68 | executor.nextSequence++ 69 | scheduledTask, err := CreateScheduledRunnableTask(executor.nextSequence, task, executor.calculateTriggerTime(delay), 0, false) 70 | executor.executorMu.Unlock() 71 | 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | executor.addNewTask(scheduledTask) 77 | 78 | return scheduledTask, nil 79 | } 80 | 81 | func (executor *SimpleTaskExecutor) ScheduleWithFixedDelay(task Task, initialDelay time.Duration, delay time.Duration) (ScheduledTask, error) { 82 | if task == nil { 83 | return nil, errors.New("task cannot be nil") 84 | } 85 | 86 | executor.executorMu.Lock() 87 | 88 | if executor.isShutdown { 89 | executor.executorMu.Unlock() 90 | return nil, errors.New("no new task won't be accepted because executor is already shut down") 91 | } 92 | 93 | executor.nextSequence++ 94 | scheduledTask, err := CreateScheduledRunnableTask(executor.nextSequence, task, executor.calculateTriggerTime(initialDelay), delay, false) 95 | executor.executorMu.Unlock() 96 | 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | executor.addNewTask(scheduledTask) 102 | 103 | return scheduledTask, nil 104 | } 105 | 106 | func (executor *SimpleTaskExecutor) ScheduleAtFixedRate(task Task, initialDelay time.Duration, period time.Duration) (ScheduledTask, error) { 107 | if task == nil { 108 | return nil, errors.New("task cannot be nil") 109 | } 110 | 111 | executor.executorMu.Lock() 112 | 113 | if executor.isShutdown { 114 | executor.executorMu.Unlock() 115 | return nil, errors.New("no new task won't be accepted because executor is already shut down") 116 | } 117 | 118 | executor.nextSequence++ 119 | scheduledTask, err := CreateScheduledRunnableTask(executor.nextSequence, task, executor.calculateTriggerTime(initialDelay), period, true) 120 | executor.executorMu.Unlock() 121 | 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | executor.addNewTask(scheduledTask) 127 | 128 | return scheduledTask, nil 129 | } 130 | 131 | func (executor *SimpleTaskExecutor) IsShutdown() bool { 132 | executor.executorMu.Lock() 133 | defer executor.executorMu.Unlock() 134 | return executor.isShutdown 135 | } 136 | 137 | func (executor *SimpleTaskExecutor) Shutdown() chan bool { 138 | executor.executorMu.Lock() 139 | defer executor.executorMu.Unlock() 140 | 141 | if executor.isShutdown { 142 | panic("executor is already shut down") 143 | } 144 | 145 | executor.isShutdown = true 146 | 147 | stoppedChan := make(chan bool) 148 | executor.shutdownChannel <- stoppedChan 149 | return stoppedChan 150 | } 151 | 152 | func (executor *SimpleTaskExecutor) calculateTriggerTime(delay time.Duration) time.Time { 153 | if delay < 0 { 154 | delay = 0 155 | } 156 | 157 | return time.Now().Add(delay) 158 | } 159 | 160 | func (executor *SimpleTaskExecutor) addNewTask(task *ScheduledRunnableTask) { 161 | executor.newTaskChannel <- task 162 | } 163 | 164 | func (executor *SimpleTaskExecutor) run() { 165 | 166 | for { 167 | executor.taskQueue.SorByTriggerTime() 168 | 169 | if len(executor.taskQueue) == 0 { 170 | executor.timer.Stop() 171 | } else { 172 | executor.timer.Reset(executor.taskQueue[0].getDelay()) 173 | } 174 | 175 | for { 176 | select { 177 | case clock := <-executor.timer.C: 178 | executor.timer.Stop() 179 | 180 | taskIndex := -1 181 | for index, scheduledTask := range executor.taskQueue { 182 | 183 | if scheduledTask.triggerTime.After(clock) || scheduledTask.triggerTime.IsZero() { 184 | break 185 | } 186 | 187 | taskIndex = index 188 | 189 | if scheduledTask.IsCancelled() { 190 | continue 191 | } 192 | 193 | if scheduledTask.isPeriodic() && scheduledTask.isFixedRate() { 194 | scheduledTask.triggerTime = scheduledTask.triggerTime.Add(scheduledTask.period) 195 | } 196 | 197 | executor.startTask(scheduledTask) 198 | } 199 | 200 | executor.taskQueue = executor.taskQueue[taskIndex+1:] 201 | case newScheduledTask := <-executor.newTaskChannel: 202 | executor.timer.Stop() 203 | executor.taskQueue = append(executor.taskQueue, newScheduledTask) 204 | case rescheduledTask := <-executor.rescheduleTaskChannel: 205 | executor.timer.Stop() 206 | executor.taskQueue = append(executor.taskQueue, rescheduledTask) 207 | case stoppedChan := <-executor.shutdownChannel: 208 | executor.timer.Stop() 209 | executor.taskWaitGroup.Wait() 210 | stoppedChan <- true 211 | return 212 | } 213 | 214 | break 215 | } 216 | 217 | } 218 | 219 | } 220 | 221 | func (executor *SimpleTaskExecutor) startTask(scheduledRunnableTask *ScheduledRunnableTask) { 222 | executor.taskWaitGroup.Add(1) 223 | 224 | executor.taskRunner.Run(func(ctx context.Context) { 225 | defer func() { 226 | if executor.IsShutdown() { 227 | scheduledRunnableTask.Cancel() 228 | executor.taskWaitGroup.Done() 229 | return 230 | } 231 | 232 | executor.taskWaitGroup.Done() 233 | 234 | if !scheduledRunnableTask.isPeriodic() { 235 | scheduledRunnableTask.Cancel() 236 | } else { 237 | if !scheduledRunnableTask.isFixedRate() { 238 | scheduledRunnableTask.triggerTime = executor.calculateTriggerTime(scheduledRunnableTask.period) 239 | executor.rescheduleTaskChannel <- scheduledRunnableTask 240 | } 241 | } 242 | }() 243 | 244 | if scheduledRunnableTask.isPeriodic() && scheduledRunnableTask.isFixedRate() { 245 | executor.rescheduleTaskChannel <- scheduledRunnableTask 246 | } 247 | 248 | scheduledRunnableTask.task(ctx) 249 | }) 250 | 251 | } 252 | -------------------------------------------------------------------------------- /executor_test.go: -------------------------------------------------------------------------------- 1 | package chrono 2 | 3 | import ( 4 | "context" 5 | "github.com/stretchr/testify/assert" 6 | "sync/atomic" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestNewDefaultTaskExecutor(t *testing.T) { 12 | executor := NewDefaultTaskExecutor() 13 | 14 | var counter int32 15 | 16 | task, err := executor.Schedule(func(ctx context.Context) { 17 | atomic.AddInt32(&counter, 1) 18 | }, 1*time.Second) 19 | 20 | assert.Nil(t, err) 21 | 22 | <-time.After(2 * time.Second) 23 | assert.True(t, task.IsCancelled(), "scheduled task must have been cancelled") 24 | assert.True(t, counter == 1, 25 | "number of scheduled task execution must be 1, actual: %d", counter) 26 | } 27 | 28 | func TestSimpleTaskExecutor_WithoutTaskRunner(t *testing.T) { 29 | executor := NewSimpleTaskExecutor(nil) 30 | 31 | var counter int32 32 | 33 | task, err := executor.Schedule(func(ctx context.Context) { 34 | atomic.AddInt32(&counter, 1) 35 | }, 1*time.Second) 36 | 37 | assert.Nil(t, err) 38 | 39 | <-time.After(2 * time.Second) 40 | assert.True(t, task.IsCancelled(), "scheduled task must have been cancelled") 41 | assert.True(t, counter == 1, 42 | "number of scheduled task execution must be 1, actual: %d", counter) 43 | } 44 | 45 | func TestSimpleTaskExecutor_Schedule_OneShotTask(t *testing.T) { 46 | executor := NewSimpleTaskExecutor(NewDefaultTaskRunner()) 47 | 48 | var counter int32 49 | 50 | task, err := executor.Schedule(func(ctx context.Context) { 51 | atomic.AddInt32(&counter, 1) 52 | }, 1*time.Second) 53 | 54 | assert.Nil(t, err) 55 | 56 | <-time.After(2 * time.Second) 57 | assert.True(t, task.IsCancelled(), "scheduled task must have been cancelled") 58 | assert.True(t, counter == 1, 59 | "number of scheduled task execution must be 1, actual: %d", counter) 60 | } 61 | 62 | func TestSimpleTaskExecutor_ScheduleWithFixedDelay(t *testing.T) { 63 | executor := NewSimpleTaskExecutor(NewDefaultTaskRunner()) 64 | 65 | var counter int32 66 | 67 | task, err := executor.ScheduleWithFixedDelay(func(ctx context.Context) { 68 | atomic.AddInt32(&counter, 1) 69 | <-time.After(500 * time.Millisecond) 70 | }, 0, 200*time.Millisecond) 71 | 72 | assert.Nil(t, err) 73 | 74 | <-time.After(1*time.Second + 500*time.Millisecond) 75 | task.Cancel() 76 | assert.True(t, counter >= 1 && counter <= 3, 77 | "number of scheduled task execution must be between 1 and 3, actual: %d", counter) 78 | } 79 | 80 | func TestSimpleTaskExecutor_ScheduleWithFixedDelayWithInitialDelay(t *testing.T) { 81 | executor := NewSimpleTaskExecutor(NewDefaultTaskRunner()) 82 | 83 | var counter int32 84 | 85 | task, err := executor.ScheduleWithFixedDelay(func(ctx context.Context) { 86 | atomic.AddInt32(&counter, 1) 87 | <-time.After(500 * time.Millisecond) 88 | }, 1*time.Second, 200*time.Millisecond) 89 | 90 | assert.Nil(t, err) 91 | 92 | <-time.After(2*time.Second + 500*time.Millisecond) 93 | task.Cancel() 94 | assert.True(t, counter >= 1 && counter <= 3, 95 | "number of scheduled task execution must be between 1 and 3, actual: %d", counter) 96 | } 97 | 98 | func TestSimpleTaskExecutor_ScheduleAtFixedRate(t *testing.T) { 99 | executor := NewSimpleTaskExecutor(NewDefaultTaskRunner()) 100 | 101 | var counter int32 102 | 103 | task, err := executor.ScheduleAtFixedRate(func(ctx context.Context) { 104 | atomic.AddInt32(&counter, 1) 105 | }, 0, 200*time.Millisecond) 106 | 107 | assert.Nil(t, err) 108 | 109 | <-time.After(2*time.Second - 50*time.Millisecond) 110 | task.Cancel() 111 | assert.True(t, counter >= 1 && counter <= 10, 112 | "number of scheduled task execution must be between 5 and 10, actual: %d", counter) 113 | } 114 | 115 | func TestSimpleTaskExecutor_ScheduleAtFixedRateWithInitialDelay(t *testing.T) { 116 | executor := NewSimpleTaskExecutor(NewDefaultTaskRunner()) 117 | 118 | var counter int32 119 | 120 | task, err := executor.ScheduleAtFixedRate(func(ctx context.Context) { 121 | atomic.AddInt32(&counter, 1) 122 | <-time.After(500 * time.Millisecond) 123 | }, 1*time.Second, 200*time.Millisecond) 124 | 125 | assert.Nil(t, err) 126 | 127 | <-time.After(3*time.Second - 50*time.Millisecond) 128 | task.Cancel() 129 | assert.True(t, counter >= 5 && counter <= 10, 130 | "number of scheduled task execution must be between 5 and 10, actual: %d", counter) 131 | } 132 | 133 | func TestSimpleTaskExecutor_Shutdown(t *testing.T) { 134 | executor := NewSimpleTaskExecutor(NewDefaultTaskRunner()) 135 | 136 | var counter int32 137 | 138 | executor.ScheduleAtFixedRate(func(ctx context.Context) { 139 | atomic.AddInt32(&counter, 1) 140 | <-time.After(500 * time.Millisecond) 141 | }, 1*time.Second, 200*time.Millisecond) 142 | 143 | <-time.After(2 * time.Second) 144 | executor.Shutdown() 145 | 146 | expected := counter 147 | <-time.After(3 * time.Second) 148 | 149 | assert.True(t, executor.IsShutdown()) 150 | assert.Equal(t, expected, counter, 151 | "after shutdown, previously scheduled tasks should not be rescheduled", counter) 152 | } 153 | 154 | func TestSimpleTaskExecutor_NoNewTaskShouldBeAccepted_AfterShutdown(t *testing.T) { 155 | executor := NewSimpleTaskExecutor(NewDefaultTaskRunner()) 156 | executor.Shutdown() 157 | 158 | var err error 159 | _, err = executor.Schedule(func(ctx context.Context) { 160 | }, 1*time.Second) 161 | 162 | assert.NotNil(t, err) 163 | 164 | _, err = executor.ScheduleWithFixedDelay(func(ctx context.Context) { 165 | }, 1*time.Second, 1*time.Second) 166 | 167 | assert.NotNil(t, err) 168 | 169 | _, err = executor.ScheduleAtFixedRate(func(ctx context.Context) { 170 | }, 1*time.Second, 200*time.Millisecond) 171 | assert.NotNil(t, err) 172 | } 173 | 174 | func TestSimpleTaskExecutor_Schedule_MultiTasks(t *testing.T) { 175 | executor := NewSimpleTaskExecutor(NewDefaultTaskRunner()) 176 | 177 | var task1Counter int32 178 | var task2Counter int32 179 | var task3Counter int32 180 | 181 | task1, err := executor.ScheduleAtFixedRate(func(ctx context.Context) { 182 | atomic.AddInt32(&task1Counter, 1) 183 | <-time.After(500 * time.Millisecond) 184 | }, 1*time.Second, 200*time.Millisecond) 185 | 186 | assert.Nil(t, err) 187 | 188 | task2, err := executor.ScheduleWithFixedDelay(func(ctx context.Context) { 189 | atomic.AddInt32(&task2Counter, 1) 190 | <-time.After(500 * time.Millisecond) 191 | }, 0, 200*time.Millisecond) 192 | 193 | assert.Nil(t, err) 194 | 195 | task3, err := executor.ScheduleAtFixedRate(func(ctx context.Context) { 196 | atomic.AddInt32(&task3Counter, 1) 197 | }, 0, 200*time.Millisecond) 198 | 199 | assert.Nil(t, err) 200 | 201 | <-time.After(2*time.Second - 50*time.Millisecond) 202 | 203 | task1.Cancel() 204 | task2.Cancel() 205 | task3.Cancel() 206 | 207 | assert.True(t, task1Counter >= 5 && task1Counter <= 10, 208 | "number of scheduled task 1 execution must be between 5 and 10, actual: %d", task1Counter) 209 | 210 | assert.True(t, task2Counter >= 1 && task2Counter <= 3, 211 | "number of scheduled task 2 execution must be between 1 and 3, actual: %d", task2Counter) 212 | 213 | assert.True(t, task3Counter >= 1 && task3Counter <= 10, 214 | "number of scheduled task execution must be between 5 and 10, actual: %d", task3Counter) 215 | } 216 | 217 | func TestSimpleTaskExecutor_ScheduleWithNilTask(t *testing.T) { 218 | executor := NewSimpleTaskExecutor(NewDefaultTaskRunner()) 219 | 220 | var task ScheduledTask 221 | var err error 222 | 223 | task, err = executor.Schedule(nil, 1*time.Second) 224 | assert.Nil(t, task) 225 | assert.NotNil(t, err) 226 | 227 | task, err = executor.ScheduleWithFixedDelay(nil, 1*time.Second, 1*time.Second) 228 | assert.Nil(t, task) 229 | assert.NotNil(t, err) 230 | 231 | task, err = executor.ScheduleAtFixedRate(nil, 1*time.Second, 1*time.Second) 232 | assert.Nil(t, task) 233 | assert.NotNil(t, err) 234 | } 235 | 236 | func TestSimpleTaskExecutor_Shutdown_TerminatedExecutor(t *testing.T) { 237 | executor := NewSimpleTaskExecutor(NewDefaultTaskRunner()) 238 | executor.Shutdown() 239 | 240 | assert.Panics(t, func() { 241 | executor.Shutdown() 242 | }) 243 | } 244 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module codnect.io/chrono 2 | 3 | go 1.13 4 | 5 | require github.com/stretchr/testify v1.7.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 6 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 7 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 8 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 11 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 12 | -------------------------------------------------------------------------------- /runner.go: -------------------------------------------------------------------------------- 1 | package chrono 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type TaskRunner interface { 8 | Run(task Task) 9 | } 10 | 11 | type SimpleTaskRunner struct { 12 | } 13 | 14 | func NewDefaultTaskRunner() TaskRunner { 15 | return NewSimpleTaskRunner() 16 | } 17 | 18 | func NewSimpleTaskRunner() *SimpleTaskRunner { 19 | return &SimpleTaskRunner{} 20 | } 21 | 22 | func (runner *SimpleTaskRunner) Run(task Task) { 23 | go func() { 24 | task(context.Background()) 25 | }() 26 | } 27 | -------------------------------------------------------------------------------- /scheduler.go: -------------------------------------------------------------------------------- 1 | package chrono 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type TaskScheduler interface { 8 | Schedule(task Task, options ...Option) (ScheduledTask, error) 9 | ScheduleWithCron(task Task, expression string, options ...Option) (ScheduledTask, error) 10 | ScheduleWithFixedDelay(task Task, delay time.Duration, options ...Option) (ScheduledTask, error) 11 | ScheduleAtFixedRate(task Task, period time.Duration, options ...Option) (ScheduledTask, error) 12 | IsShutdown() bool 13 | Shutdown() chan bool 14 | } 15 | 16 | type SimpleTaskScheduler struct { 17 | taskExecutor TaskExecutor 18 | } 19 | 20 | func NewSimpleTaskScheduler(executor TaskExecutor) *SimpleTaskScheduler { 21 | 22 | if executor == nil { 23 | executor = NewDefaultTaskExecutor() 24 | } 25 | 26 | scheduler := &SimpleTaskScheduler{ 27 | taskExecutor: executor, 28 | } 29 | 30 | return scheduler 31 | } 32 | 33 | func NewDefaultTaskScheduler() TaskScheduler { 34 | return NewSimpleTaskScheduler(NewDefaultTaskExecutor()) 35 | } 36 | 37 | func (scheduler *SimpleTaskScheduler) Schedule(task Task, options ...Option) (ScheduledTask, error) { 38 | schedulerTask, err := CreateSchedulerTask(task, options...) 39 | 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return scheduler.taskExecutor.Schedule(task, schedulerTask.GetInitialDelay()) 45 | } 46 | 47 | func (scheduler *SimpleTaskScheduler) ScheduleWithCron(task Task, expression string, options ...Option) (ScheduledTask, error) { 48 | var schedulerTask *SchedulerTask 49 | var err error 50 | 51 | schedulerTask, err = CreateSchedulerTask(task, options...) 52 | 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | var cronTrigger *CronTrigger 58 | cronTrigger, err = CreateCronTrigger(expression, schedulerTask.location) 59 | 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | var triggerTask *TriggerTask 65 | triggerTask, err = CreateTriggerTask(schedulerTask.task, scheduler.taskExecutor, cronTrigger) 66 | 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | return triggerTask.Schedule() 72 | } 73 | 74 | func (scheduler *SimpleTaskScheduler) ScheduleWithFixedDelay(task Task, delay time.Duration, options ...Option) (ScheduledTask, error) { 75 | schedulerTask, err := CreateSchedulerTask(task, options...) 76 | 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | return scheduler.taskExecutor.ScheduleWithFixedDelay(schedulerTask.task, schedulerTask.GetInitialDelay(), delay) 82 | } 83 | 84 | func (scheduler *SimpleTaskScheduler) ScheduleAtFixedRate(task Task, period time.Duration, options ...Option) (ScheduledTask, error) { 85 | schedulerTask, err := CreateSchedulerTask(task, options...) 86 | 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | return scheduler.taskExecutor.ScheduleAtFixedRate(schedulerTask.task, schedulerTask.GetInitialDelay(), period) 92 | } 93 | 94 | func (scheduler *SimpleTaskScheduler) IsShutdown() bool { 95 | return scheduler.taskExecutor.IsShutdown() 96 | } 97 | 98 | func (scheduler *SimpleTaskScheduler) Shutdown() chan bool { 99 | return scheduler.taskExecutor.Shutdown() 100 | } 101 | -------------------------------------------------------------------------------- /scheduler_test.go: -------------------------------------------------------------------------------- 1 | package chrono 2 | 3 | import ( 4 | "context" 5 | "github.com/stretchr/testify/assert" 6 | "sync/atomic" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestDefaultTaskScheduler(t *testing.T) { 12 | scheduler := NewDefaultTaskScheduler() 13 | 14 | var counter int32 15 | now := time.Now() 16 | 17 | task, err := scheduler.Schedule(func(ctx context.Context) { 18 | atomic.AddInt32(&counter, 1) 19 | }, WithStartTime(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second()+1)) 20 | 21 | assert.Nil(t, err) 22 | 23 | <-time.After(2 * time.Second) 24 | assert.True(t, task.IsCancelled(), "scheduled task must have been cancelled") 25 | assert.True(t, counter == 1, 26 | "number of scheduled task execution must be 1, actual: %d", counter) 27 | } 28 | 29 | func TestDefaultTaskSchedulerWithTimeOption(t *testing.T) { 30 | scheduler := NewDefaultTaskScheduler() 31 | 32 | var counter int32 33 | now := time.Now() 34 | starTime := now.Add(time.Second * 1) 35 | 36 | task, err := scheduler.Schedule(func(ctx context.Context) { 37 | atomic.AddInt32(&counter, 1) 38 | }, WithTime(starTime)) 39 | 40 | assert.Nil(t, err) 41 | 42 | <-time.After(2 * time.Second) 43 | assert.True(t, task.IsCancelled(), "scheduled task must have been cancelled") 44 | assert.True(t, counter == 1, 45 | "number of scheduled task execution must be 1, actual: %d", counter) 46 | } 47 | 48 | func TestSimpleTaskScheduler_ScheduleWithoutTask(t *testing.T) { 49 | scheduler := NewDefaultTaskScheduler() 50 | task, err := scheduler.Schedule(nil) 51 | assert.Error(t, err) 52 | assert.Nil(t, task) 53 | } 54 | 55 | func TestSimpleTaskScheduler_ScheduleWithFixedDelayWithoutTask(t *testing.T) { 56 | scheduler := NewDefaultTaskScheduler() 57 | task, err := scheduler.ScheduleWithFixedDelay(nil, 2*time.Second) 58 | assert.Error(t, err) 59 | assert.Nil(t, task) 60 | } 61 | 62 | func TestSimpleTaskScheduler_ScheduleAtFixedRateWithoutTask(t *testing.T) { 63 | scheduler := NewDefaultTaskScheduler() 64 | task, err := scheduler.ScheduleAtFixedRate(nil, 2*time.Second) 65 | assert.Error(t, err) 66 | assert.Nil(t, task) 67 | } 68 | 69 | func TestSimpleTaskScheduler_ScheduleWithCronWithoutTask(t *testing.T) { 70 | scheduler := NewDefaultTaskScheduler() 71 | task, err := scheduler.ScheduleWithCron(nil, "* * * * * *") 72 | assert.Error(t, err) 73 | assert.Nil(t, task) 74 | } 75 | 76 | func TestSimpleTaskScheduler_ScheduleWithCronUsingInvalidCronExpresion(t *testing.T) { 77 | scheduler := NewDefaultTaskScheduler() 78 | task, err := scheduler.ScheduleWithCron(func(ctx context.Context) { 79 | 80 | }, "test * * * * *") 81 | assert.Error(t, err) 82 | assert.Nil(t, task) 83 | } 84 | 85 | func TestSimpleTaskScheduler_WithoutScheduledExecutor(t *testing.T) { 86 | scheduler := NewSimpleTaskScheduler(nil) 87 | 88 | var counter int32 89 | now := time.Now() 90 | 91 | task, err := scheduler.Schedule(func(ctx context.Context) { 92 | atomic.AddInt32(&counter, 1) 93 | }, WithStartTime(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second()+1)) 94 | 95 | assert.Nil(t, err) 96 | 97 | <-time.After(2 * time.Second) 98 | assert.True(t, task.IsCancelled(), "scheduled task must have been cancelled") 99 | assert.True(t, counter == 1, 100 | "number of scheduled task execution must be 1, actual: %d", counter) 101 | } 102 | 103 | func TestSimpleTaskScheduler_WithoutScheduledExecutorWithTimeOption(t *testing.T) { 104 | scheduler := NewSimpleTaskScheduler(nil) 105 | 106 | var counter int32 107 | now := time.Now() 108 | startTime := now.Add(time.Second * 1) 109 | 110 | task, err := scheduler.Schedule(func(ctx context.Context) { 111 | atomic.AddInt32(&counter, 1) 112 | }, WithTime(startTime)) 113 | 114 | assert.Nil(t, err) 115 | 116 | <-time.After(2 * time.Second) 117 | assert.True(t, task.IsCancelled(), "scheduled task must have been cancelled") 118 | assert.True(t, counter == 1, 119 | "number of scheduled task execution must be 1, actual: %d", counter) 120 | } 121 | 122 | func TestSimpleTaskScheduler_ScheduleOneShotTaskWithStartTimeOption(t *testing.T) { 123 | scheduler := NewSimpleTaskScheduler(NewDefaultTaskExecutor()) 124 | 125 | var counter int32 126 | now := time.Now() 127 | 128 | task, err := scheduler.Schedule(func(ctx context.Context) { 129 | atomic.AddInt32(&counter, 1) 130 | }, WithStartTime(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second()+1)) 131 | 132 | assert.Nil(t, err) 133 | 134 | <-time.After(2 * time.Second) 135 | assert.True(t, task.IsCancelled(), "scheduled task must have been cancelled") 136 | assert.True(t, counter == 1, 137 | "number of scheduled task execution must be 1, actual: %d", counter) 138 | } 139 | 140 | func TestSimpleTaskScheduler_ScheduleOneShotTaskWithTimeOption(t *testing.T) { 141 | scheduler := NewSimpleTaskScheduler(NewDefaultTaskExecutor()) 142 | 143 | var counter int32 144 | now := time.Now() 145 | startTime := now.Add(time.Second * 1) 146 | 147 | task, err := scheduler.Schedule(func(ctx context.Context) { 148 | atomic.AddInt32(&counter, 1) 149 | }, WithTime(startTime)) 150 | 151 | assert.Nil(t, err) 152 | 153 | <-time.After(2 * time.Second) 154 | assert.True(t, task.IsCancelled(), "scheduled task must have been cancelled") 155 | assert.True(t, counter == 1, 156 | "number of scheduled task execution must be 1, actual: %d", counter) 157 | } 158 | 159 | func TestSimpleTaskScheduler_ScheduleWithFixedDelay(t *testing.T) { 160 | scheduler := NewSimpleTaskScheduler(NewDefaultTaskExecutor()) 161 | 162 | var counter int32 163 | 164 | task, err := scheduler.ScheduleWithFixedDelay(func(ctx context.Context) { 165 | atomic.AddInt32(&counter, 1) 166 | <-time.After(500 * time.Millisecond) 167 | }, 200*time.Millisecond) 168 | 169 | assert.Nil(t, err) 170 | 171 | <-time.After(1*time.Second + 500*time.Millisecond) 172 | task.Cancel() 173 | assert.True(t, counter >= 1 && counter <= 3, 174 | "number of scheduled task execution must be between 1 and 3, actual: %d", counter) 175 | } 176 | 177 | func TestSimpleTaskScheduler_ScheduleWithFixedDelayWithStartTimeOption(t *testing.T) { 178 | scheduler := NewSimpleTaskScheduler(NewDefaultTaskExecutor()) 179 | 180 | var counter int32 181 | now := time.Now() 182 | 183 | task, err := scheduler.ScheduleWithFixedDelay(func(ctx context.Context) { 184 | atomic.AddInt32(&counter, 1) 185 | <-time.After(500 * time.Millisecond) 186 | }, 200*time.Millisecond, WithStartTime(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second()+1)) 187 | 188 | assert.Nil(t, err) 189 | 190 | <-time.After(2*time.Second + 500*time.Millisecond) 191 | task.Cancel() 192 | assert.True(t, counter >= 1 && counter <= 3, 193 | "number of scheduled task execution must be between 1 and 3, actual: %d", counter) 194 | } 195 | 196 | func TestSimpleTaskScheduler_ScheduleWithFixedDelayWithTimeOption(t *testing.T) { 197 | scheduler := NewSimpleTaskScheduler(NewDefaultTaskExecutor()) 198 | 199 | var counter int32 200 | now := time.Now() 201 | startTime := now.Add(time.Second * 1) 202 | 203 | task, err := scheduler.ScheduleWithFixedDelay(func(ctx context.Context) { 204 | atomic.AddInt32(&counter, 1) 205 | <-time.After(500 * time.Millisecond) 206 | }, 200*time.Millisecond, WithTime(startTime)) 207 | 208 | assert.Nil(t, err) 209 | 210 | <-time.After(2*time.Second + 500*time.Millisecond) 211 | task.Cancel() 212 | assert.True(t, counter >= 1 && counter <= 3, 213 | "number of scheduled task execution must be between 1 and 3, actual: %d", counter) 214 | } 215 | 216 | func TestSimpleTaskScheduler_ScheduleAtFixedRate(t *testing.T) { 217 | scheduler := NewSimpleTaskScheduler(NewDefaultTaskExecutor()) 218 | 219 | var counter int32 220 | 221 | task, err := scheduler.ScheduleAtFixedRate(func(ctx context.Context) { 222 | atomic.AddInt32(&counter, 1) 223 | }, 200*time.Millisecond) 224 | 225 | assert.Nil(t, err) 226 | 227 | <-time.After(1*time.Second + 950*time.Microsecond) 228 | task.Cancel() 229 | assert.True(t, counter >= 1 && counter <= 10, 230 | "number of scheduled task execution must be between 5 and 10, actual: %d", counter) 231 | } 232 | 233 | func TestSimpleTaskScheduler_ScheduleAtFixedRateWithStartTimeOption(t *testing.T) { 234 | scheduler := NewSimpleTaskScheduler(NewDefaultTaskExecutor()) 235 | 236 | var counter int32 237 | now := time.Now() 238 | 239 | task, err := scheduler.ScheduleAtFixedRate(func(ctx context.Context) { 240 | atomic.AddInt32(&counter, 1) 241 | <-time.After(500 * time.Millisecond) 242 | }, 200*time.Millisecond, WithStartTime(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second()+1)) 243 | 244 | assert.Nil(t, err) 245 | 246 | <-time.After(3*time.Second - 50*time.Millisecond) 247 | task.Cancel() 248 | assert.True(t, counter >= 5 && counter <= 10, 249 | "number of scheduled task execution must be between 5 and 10, actual: %d", counter) 250 | } 251 | 252 | func TestSimpleTaskScheduler_ScheduleAtFixedRateWithTimeOption(t *testing.T) { 253 | scheduler := NewSimpleTaskScheduler(NewDefaultTaskExecutor()) 254 | 255 | var counter int32 256 | now := time.Now() 257 | startTime := now.Add(time.Second * 1) 258 | 259 | task, err := scheduler.ScheduleAtFixedRate(func(ctx context.Context) { 260 | atomic.AddInt32(&counter, 1) 261 | <-time.After(500 * time.Millisecond) 262 | }, 200*time.Millisecond, WithTime(startTime)) 263 | 264 | assert.Nil(t, err) 265 | 266 | <-time.After(3*time.Second - 50*time.Millisecond) 267 | task.Cancel() 268 | assert.True(t, counter >= 5 && counter <= 10, 269 | "number of scheduled task execution must be between 5 and 10, actual: %d", counter) 270 | } 271 | 272 | func TestSimpleTaskScheduler_ScheduleWithCron(t *testing.T) { 273 | scheduler := NewSimpleTaskScheduler(NewDefaultTaskExecutor()) 274 | 275 | var counter int32 276 | 277 | task, err := scheduler.ScheduleWithCron(func(ctx context.Context) { 278 | atomic.AddInt32(&counter, 1) 279 | <-time.After(500 * time.Millisecond) 280 | }, "0-59/2 * * * * *") 281 | 282 | assert.Nil(t, err) 283 | 284 | <-time.After(10 * time.Second) 285 | task.Cancel() 286 | assert.True(t, counter >= 5, 287 | "number of scheduled task execution must be at least 5, actual: %d", counter) 288 | } 289 | 290 | func TestSimpleTaskScheduler_Shutdown(t *testing.T) { 291 | scheduler := NewSimpleTaskScheduler(NewDefaultTaskExecutor()) 292 | 293 | var counter int32 294 | 295 | _, err := scheduler.ScheduleAtFixedRate(func(ctx context.Context) { 296 | atomic.AddInt32(&counter, 1) 297 | <-time.After(500 * time.Millisecond) 298 | }, 1*time.Second) 299 | 300 | assert.Nil(t, err) 301 | 302 | <-time.After(2 * time.Second) 303 | scheduler.Shutdown() 304 | 305 | expected := counter 306 | <-time.After(3 * time.Second) 307 | 308 | assert.True(t, scheduler.IsShutdown()) 309 | assert.Equal(t, expected, counter, 310 | "after shutdown, previously scheduled tasks should not be rescheduled", counter) 311 | } 312 | -------------------------------------------------------------------------------- /task.go: -------------------------------------------------------------------------------- 1 | package chrono 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sort" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | type Task func(ctx context.Context) 13 | 14 | type SchedulerTask struct { 15 | task Task 16 | startTime time.Time 17 | location *time.Location 18 | } 19 | 20 | func CreateSchedulerTask(task Task, options ...Option) (*SchedulerTask, error) { 21 | if task == nil { 22 | return nil, errors.New("task cannot be nil") 23 | } 24 | 25 | runnableTask := &SchedulerTask{ 26 | task: task, 27 | startTime: time.Time{}, 28 | location: time.Local, 29 | } 30 | 31 | for _, option := range options { 32 | err := option(runnableTask) 33 | 34 | if err != nil { 35 | return nil, err 36 | } 37 | } 38 | 39 | return runnableTask, nil 40 | } 41 | 42 | func (task *SchedulerTask) GetInitialDelay() time.Duration { 43 | if task.startTime.IsZero() { 44 | return 0 45 | } 46 | 47 | now := time.Now().In(task.location) 48 | diff := time.Date(task.startTime.Year(), task.startTime.Month(), task.startTime.Day(), task.startTime.Hour(), task.startTime.Minute(), task.startTime.Second(), 0, time.Local).Sub( 49 | time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second(), 0, time.Local)) 50 | 51 | if diff < 0 { 52 | return 0 53 | } 54 | 55 | return diff 56 | } 57 | 58 | type Option func(task *SchedulerTask) error 59 | 60 | func WithTime(t time.Time) Option { 61 | return func(task *SchedulerTask) error { 62 | task.startTime = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, time.Local) 63 | 64 | if t.Location() != nil && t.Location() != time.Local { 65 | task.location = t.Location() 66 | } 67 | 68 | return nil 69 | } 70 | } 71 | 72 | // Deprecated: Use WithTime instead. 73 | func WithStartTime(year int, month time.Month, day, hour, min, sec int) Option { 74 | return func(task *SchedulerTask) error { 75 | task.startTime = time.Date(year, month, day, hour, min, sec, 0, time.Local) 76 | return nil 77 | } 78 | } 79 | 80 | func WithLocation(location string) Option { 81 | return func(task *SchedulerTask) error { 82 | loadedLocation, err := time.LoadLocation(location) 83 | 84 | if err != nil { 85 | return fmt.Errorf("location not loaded : %s", location) 86 | } 87 | 88 | task.location = loadedLocation 89 | return nil 90 | } 91 | } 92 | 93 | type ScheduledTask interface { 94 | Cancel() 95 | IsCancelled() bool 96 | } 97 | 98 | type ScheduledRunnableTask struct { 99 | id int 100 | task Task 101 | taskMu sync.RWMutex 102 | triggerTime time.Time 103 | period time.Duration 104 | fixedRate bool 105 | cancelled bool 106 | } 107 | 108 | func CreateScheduledRunnableTask(id int, task Task, triggerTime time.Time, period time.Duration, fixedRate bool) (*ScheduledRunnableTask, error) { 109 | if task == nil { 110 | return nil, errors.New("task cannot be nil") 111 | } 112 | 113 | if period < 0 { 114 | period = 0 115 | } 116 | 117 | return &ScheduledRunnableTask{ 118 | id: id, 119 | task: task, 120 | triggerTime: triggerTime, 121 | period: period, 122 | fixedRate: fixedRate, 123 | }, nil 124 | } 125 | 126 | func (scheduledRunnableTask *ScheduledRunnableTask) Cancel() { 127 | scheduledRunnableTask.taskMu.Lock() 128 | defer scheduledRunnableTask.taskMu.Unlock() 129 | scheduledRunnableTask.cancelled = true 130 | } 131 | 132 | func (scheduledRunnableTask *ScheduledRunnableTask) IsCancelled() bool { 133 | scheduledRunnableTask.taskMu.Lock() 134 | defer scheduledRunnableTask.taskMu.Unlock() 135 | return scheduledRunnableTask.cancelled 136 | } 137 | 138 | func (scheduledRunnableTask *ScheduledRunnableTask) getDelay() time.Duration { 139 | return scheduledRunnableTask.triggerTime.Sub(time.Now()) 140 | } 141 | 142 | func (scheduledRunnableTask *ScheduledRunnableTask) isPeriodic() bool { 143 | return scheduledRunnableTask.period != 0 144 | } 145 | 146 | func (scheduledRunnableTask *ScheduledRunnableTask) isFixedRate() bool { 147 | return scheduledRunnableTask.fixedRate 148 | } 149 | 150 | type ScheduledTaskQueue []*ScheduledRunnableTask 151 | 152 | func (queue ScheduledTaskQueue) Len() int { 153 | return len(queue) 154 | } 155 | 156 | func (queue ScheduledTaskQueue) Swap(i, j int) { 157 | queue[i], queue[j] = queue[j], queue[i] 158 | } 159 | 160 | func (queue ScheduledTaskQueue) Less(i, j int) bool { 161 | return queue[i].triggerTime.Before(queue[j].triggerTime) 162 | } 163 | 164 | func (queue ScheduledTaskQueue) SorByTriggerTime() { 165 | sort.Sort(queue) 166 | } 167 | 168 | type TriggerTask struct { 169 | task Task 170 | currentScheduledTask *ScheduledRunnableTask 171 | executor TaskExecutor 172 | triggerContext *SimpleTriggerContext 173 | triggerContextMu sync.RWMutex 174 | trigger Trigger 175 | nextTriggerTime time.Time 176 | } 177 | 178 | func CreateTriggerTask(task Task, executor TaskExecutor, trigger Trigger) (*TriggerTask, error) { 179 | if task == nil { 180 | return nil, errors.New("task cannot be nil") 181 | } 182 | 183 | if executor == nil { 184 | return nil, errors.New("executor cannot be nil") 185 | } 186 | 187 | if trigger == nil { 188 | return nil, errors.New("trigger cannot be nil") 189 | } 190 | 191 | return &TriggerTask{ 192 | task: task, 193 | executor: executor, 194 | triggerContext: NewSimpleTriggerContext(), 195 | trigger: trigger, 196 | }, nil 197 | } 198 | 199 | func (task *TriggerTask) Cancel() { 200 | task.triggerContextMu.Lock() 201 | defer task.triggerContextMu.Unlock() 202 | task.currentScheduledTask.Cancel() 203 | } 204 | 205 | func (task *TriggerTask) IsCancelled() bool { 206 | task.triggerContextMu.Lock() 207 | defer task.triggerContextMu.Unlock() 208 | return task.currentScheduledTask.IsCancelled() 209 | } 210 | 211 | func (task *TriggerTask) Schedule() (ScheduledTask, error) { 212 | task.triggerContextMu.Lock() 213 | defer task.triggerContextMu.Unlock() 214 | 215 | task.nextTriggerTime = task.trigger.NextExecutionTime(task.triggerContext) 216 | 217 | if task.nextTriggerTime.IsZero() { 218 | return nil, errors.New("could not schedule task because of the fact that schedule time is zero") 219 | } 220 | 221 | initialDelay := task.nextTriggerTime.Sub(time.Now()) 222 | 223 | currentScheduledTask, err := task.executor.Schedule(task.Run, initialDelay) 224 | 225 | if err != nil { 226 | return nil, err 227 | } 228 | 229 | task.currentScheduledTask = currentScheduledTask.(*ScheduledRunnableTask) 230 | return task, nil 231 | } 232 | 233 | func (task *TriggerTask) Run(ctx context.Context) { 234 | task.triggerContextMu.Lock() 235 | 236 | executionTime := time.Now() 237 | task.task(ctx) 238 | completionTime := time.Now() 239 | 240 | task.triggerContext.Update(completionTime, executionTime, task.nextTriggerTime) 241 | task.triggerContextMu.Unlock() 242 | 243 | if !task.IsCancelled() { 244 | task.Schedule() 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /task_test.go: -------------------------------------------------------------------------------- 1 | package chrono 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/mock" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestNewSchedulerTask(t *testing.T) { 13 | _, err := CreateSchedulerTask(nil) 14 | assert.Error(t, err) 15 | } 16 | 17 | func TestNewSchedulerTask_WithLocation(t *testing.T) { 18 | _, err := CreateSchedulerTask(func(ctx context.Context) { 19 | 20 | }, WithLocation("Europe/Istanbul")) 21 | assert.Nil(t, err) 22 | } 23 | 24 | func TestNewSchedulerTask_WithInvalidLocation(t *testing.T) { 25 | _, err := CreateSchedulerTask(func(ctx context.Context) { 26 | 27 | }, WithLocation("Europe")) 28 | assert.Error(t, err) 29 | } 30 | 31 | func TestNewScheduledRunnableTask(t *testing.T) { 32 | task, _ := CreateScheduledRunnableTask(0, func(ctx context.Context) { 33 | 34 | }, time.Now(), -1, true) 35 | 36 | assert.Equal(t, task.period, 0*time.Second) 37 | 38 | _, err := CreateScheduledRunnableTask(0, nil, time.Now(), -1, true) 39 | assert.Error(t, err) 40 | } 41 | 42 | func TestNewTriggerTask(t *testing.T) { 43 | trigger, err := CreateCronTrigger("* * * * * *", time.Local) 44 | assert.Nil(t, err) 45 | 46 | _, err = CreateTriggerTask(nil, NewDefaultTaskExecutor(), trigger) 47 | assert.Error(t, err) 48 | 49 | _, err = CreateTriggerTask(func(ctx context.Context) { 50 | 51 | }, nil, trigger) 52 | assert.Error(t, err) 53 | 54 | _, err = CreateTriggerTask(func(ctx context.Context) { 55 | 56 | }, NewDefaultTaskExecutor(), nil) 57 | } 58 | 59 | type zeroTrigger struct { 60 | } 61 | 62 | func (trigger *zeroTrigger) NextExecutionTime(ctx TriggerContext) time.Time { 63 | return time.Time{} 64 | } 65 | 66 | func TestTriggerTask_Schedule(t *testing.T) { 67 | task, _ := CreateTriggerTask(func(ctx context.Context) {}, NewDefaultTaskExecutor(), &zeroTrigger{}) 68 | _, err := task.Schedule() 69 | assert.NotNil(t, err) 70 | } 71 | 72 | type scheduledExecutorMock struct { 73 | mock.Mock 74 | } 75 | 76 | func (executor scheduledExecutorMock) Schedule(task Task, delay time.Duration) (ScheduledTask, error) { 77 | result := executor.Called(task, delay) 78 | return result.Get(0).(ScheduledTask), result.Error(1) 79 | } 80 | 81 | func (executor scheduledExecutorMock) ScheduleWithFixedDelay(task Task, initialDelay time.Duration, delay time.Duration) (ScheduledTask, error) { 82 | result := executor.Called(task, initialDelay, delay) 83 | return result.Get(0).(ScheduledTask), result.Error(1) 84 | } 85 | 86 | func (executor scheduledExecutorMock) ScheduleAtFixedRate(task Task, initialDelay time.Duration, period time.Duration) (ScheduledTask, error) { 87 | result := executor.Called(task, initialDelay, period) 88 | return result.Get(0).(ScheduledTask), result.Error(1) 89 | } 90 | 91 | func (executor scheduledExecutorMock) IsShutdown() bool { 92 | result := executor.Called() 93 | return result.Bool(0) 94 | } 95 | 96 | func (executor scheduledExecutorMock) Shutdown() chan bool { 97 | result := executor.Called() 98 | return result.Get(0).(chan bool) 99 | } 100 | 101 | func TestTriggerTask_ScheduleWithError(t *testing.T) { 102 | scheduledExecutorMock := &scheduledExecutorMock{} 103 | 104 | scheduledExecutorMock.On("Schedule", mock.AnythingOfType("Task"), mock.AnythingOfType("time.Duration")). 105 | Return((*ScheduledRunnableTask)(nil), errors.New("test error")) 106 | 107 | trigger, err := CreateCronTrigger("* * * * * *", time.Local) 108 | assert.Nil(t, err) 109 | 110 | task, _ := CreateTriggerTask(func(ctx context.Context) {}, scheduledExecutorMock, trigger) 111 | _, err = task.Schedule() 112 | 113 | assert.NotNil(t, err) 114 | } 115 | -------------------------------------------------------------------------------- /trigger.go: -------------------------------------------------------------------------------- 1 | package chrono 2 | 3 | import "time" 4 | 5 | type TriggerContext interface { 6 | LastCompletionTime() time.Time 7 | LastExecutionTime() time.Time 8 | LastTriggeredExecutionTime() time.Time 9 | } 10 | 11 | type SimpleTriggerContext struct { 12 | lastCompletionTime time.Time 13 | lastExecutionTime time.Time 14 | lastTriggeredExecutionTime time.Time 15 | } 16 | 17 | func NewSimpleTriggerContext() *SimpleTriggerContext { 18 | return &SimpleTriggerContext{} 19 | } 20 | 21 | func (ctx *SimpleTriggerContext) Update(lastCompletionTime time.Time, lastExecutionTime time.Time, lastTriggeredExecutionTime time.Time) { 22 | ctx.lastCompletionTime = lastCompletionTime 23 | ctx.lastExecutionTime = lastExecutionTime 24 | ctx.lastTriggeredExecutionTime = lastTriggeredExecutionTime 25 | } 26 | 27 | func (ctx *SimpleTriggerContext) LastCompletionTime() time.Time { 28 | return ctx.lastCompletionTime 29 | } 30 | 31 | func (ctx *SimpleTriggerContext) LastExecutionTime() time.Time { 32 | return ctx.lastExecutionTime 33 | } 34 | 35 | func (ctx *SimpleTriggerContext) LastTriggeredExecutionTime() time.Time { 36 | return ctx.lastTriggeredExecutionTime 37 | } 38 | 39 | type Trigger interface { 40 | NextExecutionTime(ctx TriggerContext) time.Time 41 | } 42 | 43 | type CronTrigger struct { 44 | cronExpression *CronExpression 45 | location *time.Location 46 | } 47 | 48 | func CreateCronTrigger(expression string, location *time.Location) (*CronTrigger, error) { 49 | cron, err := ParseCronExpression(expression) 50 | 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | trigger := &CronTrigger{ 56 | cron, 57 | time.Local, 58 | } 59 | 60 | if location != nil { 61 | trigger.location = location 62 | } 63 | 64 | return trigger, nil 65 | } 66 | 67 | func (trigger *CronTrigger) NextExecutionTime(ctx TriggerContext) time.Time { 68 | now := time.Now() 69 | lastCompletion := ctx.LastCompletionTime() 70 | 71 | if !lastCompletion.IsZero() { 72 | 73 | lastExecution := ctx.LastTriggeredExecutionTime() 74 | 75 | if !lastExecution.IsZero() && now.Before(lastExecution) { 76 | now = lastExecution 77 | } 78 | 79 | } 80 | 81 | originalLocation := now.Location() 82 | 83 | convertedTime := now.In(trigger.location) 84 | convertedTime = time.Date(convertedTime.Year(), 85 | convertedTime.Month(), 86 | convertedTime.Day(), 87 | convertedTime.Hour(), 88 | convertedTime.Minute(), 89 | convertedTime.Second(), 90 | convertedTime.Nanosecond(), 91 | trigger.location) 92 | 93 | next := trigger.cronExpression.NextTime(convertedTime) 94 | 95 | // there is a bug causes timezone changing when an operation is performed on time value like add, subtraction 96 | // to resolve this issue, we use a workaround solution 97 | next = time.Date(next.Year(), 98 | next.Month(), 99 | next.Day(), 100 | next.Hour(), 101 | next.Minute(), 102 | next.Second(), 103 | next.Nanosecond(), 104 | trigger.location) 105 | 106 | return next.In(originalLocation) 107 | } 108 | -------------------------------------------------------------------------------- /trigger_test.go: -------------------------------------------------------------------------------- 1 | package chrono 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestSimpleTriggerContext(t *testing.T) { 10 | ctx := NewSimpleTriggerContext() 11 | now := time.Now() 12 | ctx.lastExecutionTime = now 13 | ctx.lastTriggeredExecutionTime = now 14 | ctx.lastCompletionTime = now 15 | 16 | assert.Equal(t, now, ctx.LastExecutionTime()) 17 | assert.Equal(t, now, ctx.LastCompletionTime()) 18 | assert.Equal(t, now, ctx.LastTriggeredExecutionTime()) 19 | } 20 | 21 | func TestNewCronTrigger(t *testing.T) { 22 | trigger, err := CreateCronTrigger("", time.Local) 23 | assert.Error(t, err) 24 | assert.Nil(t, trigger) 25 | } 26 | --------------------------------------------------------------------------------