├── .editorconfig ├── .github ├── CODEOWNERS ├── FUNDING.yml └── workflows │ ├── codeql-analysis.yml │ ├── lint-action.yml │ ├── release.yml │ └── test-action.yml ├── .gitignore ├── .goreleaser.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── VERSION ├── batch.go ├── batch_test.go ├── checker.go ├── cmd └── tasker │ ├── main.go │ └── main_test.go ├── go.mod ├── gronx.go ├── gronx_test.go ├── next.go ├── next_test.go ├── pkg └── tasker │ ├── README.md │ ├── parser.go │ ├── parser_test.go │ ├── tasker.go │ ├── tasker_other.go │ ├── tasker_test.go │ └── tasker_windows.go ├── prev.go ├── prev_test.go ├── test ├── taskfile-complex.txt └── taskfile.txt └── validator.go /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.go] 12 | indent_style = tab 13 | tab_width = 2 14 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @adhocore 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: adhocore 2 | custom: ['https://paypal.me/ji10'] 3 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | schedule: 8 | - cron: '20 15 * * 6' 9 | jobs: 10 | analyze: 11 | name: Analyze 12 | runs-on: ubuntu-latest 13 | permissions: 14 | actions: read 15 | contents: read 16 | security-events: write 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | language: [ 'go' ] 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v2 24 | - name: Initialize CodeQL 25 | uses: github/codeql-action/init@v1 26 | with: 27 | languages: ${{ matrix.language }} 28 | - name: Autobuild 29 | uses: github/codeql-action/autobuild@v1 30 | - name: Perform CodeQL Analysis 31 | uses: github/codeql-action/analyze@v1 32 | -------------------------------------------------------------------------------- /.github/workflows/lint-action.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: [push] 3 | jobs: 4 | golint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout code 8 | uses: actions/checkout@v2 9 | - name: Lint 10 | id: golint 11 | uses: Jerome1337/go-action/lint@master 12 | - name: Lint Output 13 | run: echo "${{ steps.golint.outputs.golint-output }}" 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | # packages: write 11 | # issues: write 12 | 13 | jobs: 14 | goreleaser: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | - run: git fetch --force --tags 21 | - uses: actions/setup-go@v4 22 | with: 23 | go-version: stable 24 | - uses: goreleaser/goreleaser-action@v4 25 | with: 26 | distribution: goreleaser 27 | version: latest 28 | args: release --clean 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/test-action.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.16.x, 1.17.x, 1.18.x, 1.19.x, 1.20.x, 1.21.x, 1.22.x, 1.23.x] 8 | os: [ubuntu-latest] 9 | runs-on: ${{ matrix.os }} 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v2 13 | - name: Install Go 14 | uses: actions/setup-go@v2 15 | with: 16 | go-version: ${{ matrix.go-version }} 17 | - name: Test 18 | run: go test -cover -coverprofile=coverage.txt -covermode=atomic ./... 19 | - name: Cov 20 | run: bash <(curl -s https://codecov.io/bash) 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store 3 | *~ 4 | *.out 5 | vendor/ 6 | dist/ 7 | .env 8 | bin/ 9 | *.php 10 | test/*.go 11 | *.txt 12 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | 2 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 3 | 4 | version: 2 5 | 6 | project_name: tasker 7 | 8 | release: 9 | prerelease: auto 10 | name_template: "Version v{{.Version}}" 11 | # draft: true 12 | mode: "keep-existing" 13 | 14 | before: 15 | hooks: 16 | - go mod tidy 17 | 18 | builds: 19 | - id: macOS 20 | binary: bin/tasker 21 | main: ./cmd/tasker 22 | ldflags: 23 | - -X main.Version={{.Version}} 24 | env: 25 | - CGO_ENABLED=0 26 | goos: [darwin] 27 | goarch: [amd64, arm64] 28 | 29 | - id: linux 30 | main: ./cmd/tasker 31 | goos: [linux] 32 | goarch: ["386", arm, amd64, arm64] 33 | 34 | - id: windows 35 | main: ./cmd/tasker 36 | goos: [windows] 37 | goarch: [amd64] 38 | 39 | archives: 40 | - id: nix 41 | ids: [macOS, linux] 42 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" 43 | wrap_in_directory: true 44 | format: tar.gz 45 | files: 46 | - LICENSE 47 | 48 | - id: windows 49 | ids: [windows] 50 | wrap_in_directory: false 51 | format: zip 52 | files: 53 | - LICENSE 54 | 55 | checksum: 56 | name_template: 'checksums.txt' 57 | algorithm: sha256 58 | 59 | changelog: 60 | disable: true 61 | use: github 62 | sort: desc 63 | filters: 64 | exclude: 65 | - '^doc:' 66 | - '^dev:' 67 | - '^build:' 68 | - '^ci:' 69 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [v0.2.7](https://github.com/adhocore/gronx/releases/tag/v0.2.7) (2022-06-28) 2 | 3 | ### Miscellaneous 4 | - **Workflow**: Run tests on 1.18x (Jitendra) 5 | - Tests for go v1.17.x, add codecov (Jitendra) 6 | 7 | 8 | ## [v0.2.6](https://github.com/adhocore/gronx/releases/tag/v0.2.6) (2021-10-14) 9 | 10 | ### Miscellaneous 11 | - Fix 'with' languages (Jitendra Adhikari) [_a813b55_](https://github.com/adhocore/gronx/commit/a813b55) 12 | - Init/setup github codeql (Jitendra Adhikari) [_fe2aa5a_](https://github.com/adhocore/gronx/commit/fe2aa5a) 13 | 14 | 15 | ## [v0.2.5](https://github.com/adhocore/gronx/releases/tag/v0.2.5) (2021-07-25) 16 | 17 | ### Bug Fixes 18 | - **Tasker**: The clause should be using OR (Jitendra Adhikari) [_b813b85_](https://github.com/adhocore/gronx/commit/b813b85) 19 | 20 | 21 | ## [v0.2.4](https://github.com/adhocore/gronx/releases/tag/v0.2.4) (2021-05-05) 22 | 23 | ### Features 24 | - **Pkg.tasker**: Capture cmd output in tasker logger, error in stderr (Jitendra Adhikari) [_0da0aae_](https://github.com/adhocore/gronx/commit/0da0aae) 25 | 26 | ### Internal Refactors 27 | - **Cmd.tasker**: Taskify is now method of tasker (Jitendra Adhikari) [_8b1373b_](https://github.com/adhocore/gronx/commit/8b1373b) 28 | 29 | 30 | ## [v0.2.3](https://github.com/adhocore/gronx/releases/tag/v0.2.3) (2021-05-04) 31 | 32 | ### Bug Fixes 33 | - **Pkg.tasker**: Sleep 100ms so abort can be bailed asap, remove dup msg (Jitendra Adhikari) [_d868920_](https://github.com/adhocore/gronx/commit/d868920) 34 | 35 | ### Miscellaneous 36 | - Allow leeway period at the end (Jitendra Adhikari) [_5ebf923_](https://github.com/adhocore/gronx/commit/5ebf923) 37 | 38 | 39 | ## [v0.2.2](https://github.com/adhocore/gronx/releases/tag/v0.2.2) (2021-05-03) 40 | 41 | ### Bug Fixes 42 | - **Pkg.tasker**: DoRun checks if timed out before run (Jitendra Adhikari) [_f27a657_](https://github.com/adhocore/gronx/commit/f27a657) 43 | 44 | ### Internal Refactors 45 | - **Pkg.tasker**: Use dateFormat var, update final tick phrase (Jitendra Adhikari) [_fad0271_](https://github.com/adhocore/gronx/commit/fad0271) 46 | 47 | 48 | ## [v0.2.1](https://github.com/adhocore/gronx/releases/tag/v0.2.1) (2021-05-02) 49 | 50 | ### Bug Fixes 51 | - **Pkg.tasker**: Deprecate sleep dur if next tick timeout (Jitendra Adhikari) [_3de45a1_](https://github.com/adhocore/gronx/commit/3de45a1) 52 | 53 | 54 | ## [v0.2.0](https://github.com/adhocore/gronx/releases/tag/v0.2.0) (2021-05-02) 55 | 56 | ### Features 57 | - **Cmd.tasker**: Add tasker for standalone usage as task daemon (Jitendra Adhikari) [_0d99409_](https://github.com/adhocore/gronx/commit/0d99409) 58 | - **Pkg.tasker**: Add parser for tasker pkg (Jitendra Adhikari) [_e7f1811_](https://github.com/adhocore/gronx/commit/e7f1811) 59 | - **Pkg.tasker**: Add tasker pkg (Jitendra Adhikari) [_a57b1c4_](https://github.com/adhocore/gronx/commit/a57b1c4) 60 | 61 | ### Bug Fixes 62 | - **Pkg.tasker**: Use log.New() instead (Jitendra Adhikari) [_0cf2c07_](https://github.com/adhocore/gronx/commit/0cf2c07) 63 | - **Validator**: This check is not really required (Jitendra Adhikari) [_c3d75e3_](https://github.com/adhocore/gronx/commit/c3d75e3) 64 | 65 | ### Internal Refactors 66 | - **Gronx**: Add public methods for internal usage, expose spaceRe (Jitendra Adhikari) [_94eb20b_](https://github.com/adhocore/gronx/commit/94eb20b) 67 | 68 | ### Miscellaneous 69 | - **Pkg.tasker**: Use file perms as octal (Jitendra Adhikari) [_83f258d_](https://github.com/adhocore/gronx/commit/83f258d) 70 | - **Workflow**: Include all tests in action (Jitendra Adhikari) [_7328cbf_](https://github.com/adhocore/gronx/commit/7328cbf) 71 | 72 | ### Documentations 73 | - Add task mangager and tasker docs/usages (Jitendra Adhikari) [_e77aa5f_](https://github.com/adhocore/gronx/commit/e77aa5f) 74 | 75 | 76 | ## [v0.1.4](https://github.com/adhocore/gronx/releases/tag/v0.1.4) (2021-04-25) 77 | 78 | ### Miscellaneous 79 | - **Mod**: 1.13 is okay too (Jitendra Adhikari) [_6c328e7_](https://github.com/adhocore/gronx/commit/6c328e7) 80 | - Try go 1.13.x (Jitendra Adhikari) [_b017ec4_](https://github.com/adhocore/gronx/commit/b017ec4) 81 | 82 | ### Documentations 83 | - Practical usage (Jitendra Adhikari) [_9572e61_](https://github.com/adhocore/gronx/commit/9572e61) 84 | 85 | 86 | ## [v0.1.3](https://github.com/adhocore/gronx/releases/tag/v0.1.3) (2021-04-22) 87 | 88 | ### Internal Refactors 89 | - **Checker**: Preserve error, for pos 2 & 4 bail only on due or err (Jitendra Adhikari) [_39a9cd5_](https://github.com/adhocore/gronx/commit/39a9cd5) 90 | - **Validator**: Do not discard error from strconv (Jitendra Adhikari) [_3b0f444_](https://github.com/adhocore/gronx/commit/3b0f444) 91 | 92 | 93 | ## [v0.1.2](https://github.com/adhocore/gronx/releases/tag/v0.1.2) (2021-04-21) 94 | 95 | ### Features 96 | - Add IsValid() (Jitendra Adhikari) [_150687b_](https://github.com/adhocore/gronx/commit/150687b) 97 | 98 | ### Documentations 99 | - IsValid usage (Jitendra Adhikari) [_b747116_](https://github.com/adhocore/gronx/commit/b747116) 100 | 101 | 102 | ## [v0.1.1](https://github.com/adhocore/gronx/releases/tag/v0.1.1) (2021-04-21) 103 | 104 | ### Features 105 | - Add main gronx api (Jitendra Adhikari) [_1b3b108_](https://github.com/adhocore/gronx/commit/1b3b108) 106 | - Add cron segment checker (Jitendra Adhikari) [_a56be7c_](https://github.com/adhocore/gronx/commit/a56be7c) 107 | - Add validator (Jitendra Adhikari) [_455a024_](https://github.com/adhocore/gronx/commit/455a024) 108 | 109 | ### Miscellaneous 110 | - **Workflow**: Update actions (Jitendra Adhikari) [_8b54cc3_](https://github.com/adhocore/gronx/commit/8b54cc3) 111 | - Init module (Jitendra Adhikari) [_bada37d_](https://github.com/adhocore/gronx/commit/bada37d) 112 | - Add license (Jitendra Adhikari) [_5f20b96_](https://github.com/adhocore/gronx/commit/5f20b96) 113 | - **Gh**: Add meta files (Jitendra Adhikari) [_35a1310_](https://github.com/adhocore/gronx/commit/35a1310) 114 | - **Workflow**: Add lint/test actions (Jitendra Adhikari) [_884d5cb_](https://github.com/adhocore/gronx/commit/884d5cb) 115 | - Add editorconfig (Jitendra Adhikari) [_8b75494_](https://github.com/adhocore/gronx/commit/8b75494) 116 | 117 | ### Documentations 118 | - On cron expressions (Jitendra Adhikari) [_547fd72_](https://github.com/adhocore/gronx/commit/547fd72) 119 | - Add readme (Jitendra Adhikari) [_3955e88_](https://github.com/adhocore/gronx/commit/3955e88) 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2099 Jitendra Adhikari 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 | # adhocore/gronx 2 | 3 | [![Latest Version](https://img.shields.io/github/release/adhocore/gronx.svg?style=flat-square)](https://github.com/adhocore/gronx/releases) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) 5 | [![Go Report](https://goreportcard.com/badge/github.com/adhocore/gronx)](https://goreportcard.com/report/github.com/adhocore/gronx) 6 | [![Test](https://github.com/adhocore/gronx/actions/workflows/test-action.yml/badge.svg)](https://github.com/adhocore/gronx/actions/workflows/test-action.yml) 7 | [![Lint](https://github.com/adhocore/gronx/actions/workflows/lint-action.yml/badge.svg)](https://github.com/adhocore/gronx/actions/workflows/lint-action.yml) 8 | [![Codecov](https://img.shields.io/codecov/c/github/adhocore/gronx/main.svg?style=flat-square)](https://codecov.io/gh/adhocore/gronx) 9 | [![Support](https://img.shields.io/static/v1?label=Support&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/adhocore) 10 | [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Lightweight+fast+and+deps+free+cron+expression+parser+for+Golang&url=https://github.com/adhocore/gronx&hashtags=go,golang,parser,cron,cronexpr,cronparser) 11 | 12 | `gronx` is Golang [cron expression](#cron-expression) parser ported from [adhocore/cron-expr](https://github.com/adhocore/php-cron-expr) with task runner 13 | and daemon that supports crontab like task list file. Use it programatically in Golang or as standalone binary instead of crond. If that's not enough, you can use gronx to find the next (`NextTick()`) or previous (`PrevTick()`) run time of an expression from any arbitrary point of time. 14 | 15 | - Zero dependency. 16 | - Very **fast** because it bails early in case a segment doesn't match. 17 | - Built in crontab like daemon. 18 | - Supports time granularity of Seconds. 19 | 20 | Find gronx in [pkg.go.dev](https://pkg.go.dev/github.com/adhocore/gronx). 21 | 22 | ## Installation 23 | 24 | ```sh 25 | go get -u github.com/adhocore/gronx 26 | ``` 27 | 28 | ## Usage 29 | 30 | ```go 31 | import ( 32 | "time" 33 | 34 | "github.com/adhocore/gronx" 35 | ) 36 | 37 | gron := gronx.New() 38 | expr := "* * * * *" 39 | 40 | // check if expr is even valid, returns bool 41 | gron.IsValid(expr) // true 42 | 43 | // check if expr is due for current time, returns bool and error 44 | gron.IsDue(expr) // true|false, nil 45 | 46 | // check if expr is due for given time 47 | gron.IsDue(expr, time.Date(2021, time.April, 1, 1, 1, 0, 0, time.UTC)) // true|false, nil 48 | ``` 49 | 50 | > Validity can be checked without instantiation: 51 | 52 | ```go 53 | import "github.com/adhocore/gronx" 54 | 55 | gronx.IsValid("* * * * *") // true 56 | ``` 57 | 58 | ### Batch Due Check 59 | 60 | If you have multiple cron expressions to check due on same reference time use `BatchDue()`: 61 | ```go 62 | gron := gronx.New() 63 | exprs := []string{"* * * * *", "0 */5 * * * *"} 64 | 65 | // gives []gronx.Expr{} array, each item has Due flag and Err enountered. 66 | dues := gron.BatchDue(exprs) 67 | 68 | for _, expr := range dues { 69 | if expr.Err != nil { 70 | // Handle err 71 | } else if expr.Due { 72 | // Handle due 73 | } 74 | } 75 | 76 | // Or with given time 77 | ref := time.Now() 78 | gron.BatchDue(exprs, ref) 79 | ``` 80 | 81 | ### Next Tick 82 | 83 | To find out when is the cron due next (in near future): 84 | ```go 85 | allowCurrent = true // includes current time as well 86 | nextTime, err := gronx.NextTick(expr, allowCurrent) // gives time.Time, error 87 | 88 | // OR, next tick after certain reference time 89 | refTime = time.Date(2022, time.November, 1, 1, 1, 0, 0, time.UTC) 90 | allowCurrent = false // excludes the ref time 91 | nextTime, err := gronx.NextTickAfter(expr, refTime, allowCurrent) // gives time.Time, error 92 | ``` 93 | 94 | ### Prev Tick 95 | 96 | To find out when was the cron due previously (in near past): 97 | ```go 98 | allowCurrent = true // includes current time as well 99 | prevTime, err := gronx.PrevTick(expr, allowCurrent) // gives time.Time, error 100 | 101 | // OR, prev tick before certain reference time 102 | refTime = time.Date(2022, time.November, 1, 1, 1, 0, 0, time.UTC) 103 | allowCurrent = false // excludes the ref time 104 | nextTime, err := gronx.PrevTickBefore(expr, refTime, allowCurrent) // gives time.Time, error 105 | ``` 106 | 107 | > The working of `PrevTick*()` and `NextTick*()` are mostly the same except the direction. 108 | > They differ in lookback or lookahead. 109 | 110 | ### Standalone Daemon 111 | 112 | In a more practical level, you would use this tool to manage and invoke jobs in app itself and not 113 | mess around with `crontab` for each and every new tasks/jobs. 114 | 115 | In crontab just put one entry with `* * * * *` which points to your Go entry point that uses this tool. 116 | Then in that entry point you would invoke different tasks if the corresponding Cron expr is due. 117 | Simple map structure would work for this. 118 | 119 | Check the section below for more sophisticated way of managing tasks automatically using `gronx` daemon called `tasker`. 120 | 121 | --- 122 | ### Go Tasker 123 | 124 | Tasker is a task manager that can be programatically used in Golang applications. It runs as a daemon and invokes tasks scheduled with cron expression: 125 | ```go 126 | package main 127 | 128 | import ( 129 | "context" 130 | "time" 131 | 132 | "github.com/adhocore/gronx/pkg/tasker" 133 | ) 134 | 135 | func main() { 136 | taskr := tasker.New(tasker.Option{ 137 | Verbose: true, 138 | // optional: defaults to local 139 | Tz: "Asia/Bangkok", 140 | // optional: defaults to stderr log stream 141 | Out: "/full/path/to/output-file", 142 | }) 143 | 144 | // add task to run every minute 145 | taskr.Task("* * * * *", func(ctx context.Context) (int, error) { 146 | // do something ... 147 | 148 | // then return exit code and error, for eg: if everything okay 149 | return 0, nil 150 | }).Task("*/5 * * * *", func(ctx context.Context) (int, error) { // every 5 minutes 151 | // you can also log the output to Out file as configured in Option above: 152 | taskr.Log.Printf("done something in %d s", 2) 153 | 154 | return 0, nil 155 | }) 156 | 157 | // run task without overlap, set concurrent flag to false: 158 | concurrent := false 159 | taskr.Task("* * * * * *", , tasker.Taskify("sleep 2", tasker.Option{}), concurrent) 160 | 161 | // every 10 minute with arbitrary command 162 | taskr.Task("@10minutes", taskr.Taskify("command --option val -- args", tasker.Option{Shell: "/bin/sh -c"})) 163 | 164 | // ... add more tasks 165 | 166 | // optionally if you want tasker to stop after 2 hour, pass the duration with Until(): 167 | taskr.Until(2 * time.Hour) 168 | 169 | // finally run the tasker, it ticks sharply on every minute and runs all the tasks due on that time! 170 | // it exits gracefully when ctrl+c is received making sure pending tasks are completed. 171 | taskr.Run() 172 | } 173 | ``` 174 | 175 | #### Concurrency 176 | 177 | By default the tasks can run concurrently i.e if previous run is still not finished 178 | but it is now due again, it will run again. 179 | If you want to run only one instance of a task at a time, set concurrent flag to false: 180 | 181 | ```go 182 | taskr := tasker.New(tasker.Option{}) 183 | 184 | concurrent := false 185 | expr, task := "* * * * * *", tasker.Taskify("php -r 'sleep(2);'") 186 | taskr.Task(expr, task, concurrent) 187 | ``` 188 | 189 | ### Task Daemon 190 | 191 | It can also be used as standalone task daemon instead of programmatic usage for Golang application. 192 | 193 | First, just install tasker command: 194 | ```sh 195 | go install github.com/adhocore/gronx/cmd/tasker@latest 196 | ``` 197 | 198 | Or you can also download latest prebuilt binary from [release](https://github.com/adhocore/gronx/releases/latest) for platform of your choice. 199 | 200 | Then prepare a taskfile ([example](./tests/../test/taskfile.txt)) in crontab format 201 | (or can even point to existing crontab). 202 | > `user` is not supported: it is just cron expr followed by the command. 203 | 204 | Finally run the task daemon like so 205 | ``` 206 | tasker -file path/to/taskfile 207 | ``` 208 | > You can pass more options to control the behavior of task daemon, see below. 209 | 210 | #### Tasker command options: 211 | 212 | ```txt 213 | -file string 214 | The task file in crontab format 215 | -out string 216 | The fullpath to file where output from tasks are sent to 217 | -shell string 218 | The shell to use for running tasks (default "/usr/bin/bash") 219 | -tz string 220 | The timezone to use for tasks (default "Local") 221 | -until int 222 | The timeout for task daemon in minutes 223 | -verbose 224 | The verbose mode outputs as much as possible 225 | ``` 226 | 227 | Examples: 228 | ```sh 229 | tasker -verbose -file path/to/taskfile -until 120 # run until next 120min (i.e 2hour) with all feedbacks echoed back 230 | tasker -verbose -file path/to/taskfile -out path/to/output # with all feedbacks echoed to the output file 231 | tasker -tz America/New_York -file path/to/taskfile -shell zsh # run all tasks using zsh shell based on NY timezone 232 | ``` 233 | 234 | > File extension of taskfile for (`-file` option) does not matter: can be any or none. 235 | > The directory for outfile (`-out` option) must exist, file is created by task daemon. 236 | 237 | > Same timezone applies for all tasks currently and it might support overriding timezone per task in future release. 238 | 239 | #### Notes on Windows 240 | 241 | In Windows if it doesn't find `bash.exe` or `git-bash.exe` it will use `powershell`. 242 | `powershell` may not be compatible with Unix flavored commands. Also to note: 243 | you can't do chaining with `cmd1 && cmd2` but rather `cmd1 ; cmd2`. 244 | 245 | --- 246 | ### Cron Expression 247 | 248 | A complete cron expression consists of 7 segments viz: 249 | ``` 250 | 251 | ``` 252 | 253 | However only 5 will do and this is most commonly used. 5 segments are interpreted as: 254 | ``` 255 | 256 | ``` 257 | in which case a default value of 0 is prepended for `` position. 258 | 259 | In a 6 segments expression, if 6th segment matches `` (i.e 4 digits at least) it will be interpreted as: 260 | ``` 261 | 262 | ``` 263 | and a default value of 0 is prepended for `` position. 264 | 265 | For each segments you can have **multiple choices** separated by comma: 266 | > Eg: `0 0,30 * * * *` means either 0th or 30th minute. 267 | 268 | To specify **range of values** you can use dash: 269 | > Eg: `0 10-15 * * * *` means 10th, 11th, 12th, 13th, 14th and 15th minute. 270 | 271 | To specify **range of step** you can combine a dash and slash: 272 | > Eg: `0 10-15/2 * * * *` means every 2 minutes between 10 and 15 i.e 10th, 12th and 14th minute. 273 | 274 | For the `` and `` segment, there are additional [**modifiers**](#modifiers) (optional). 275 | 276 | And if you want, you can mix the multiple choices, ranges and steps in a single expression: 277 | > `0 5,12-20/4,55 * * * *` matches if any one of `5` or `12-20/4` or `55` matches the minute. 278 | 279 | ### Real Abbreviations 280 | 281 | You can use real abbreviations (3 chars) for month and week days. eg: `JAN`, `dec`, `fri`, `SUN` 282 | 283 | ### Tags 284 | 285 | Following tags are available and they are converted to real cron expressions before parsing: 286 | 287 | - *@yearly* or *@annually* - every year 288 | - *@monthly* - every month 289 | - *@daily* - every day 290 | - *@weekly* - every week 291 | - *@hourly* - every hour 292 | - *@5minutes* - every 5 minutes 293 | - *@10minutes* - every 10 minutes 294 | - *@15minutes* - every 15 minutes 295 | - *@30minutes* - every 30 minutes 296 | - *@always* - every minute 297 | - *@everysecond* - every second 298 | 299 | > For BC reasons, `@always` still means every minute for now, in future release it may mean every seconds instead. 300 | 301 | ```go 302 | // Use tags like so: 303 | gron.IsDue("@hourly") 304 | gron.IsDue("@5minutes") 305 | ``` 306 | 307 | ### Modifiers 308 | 309 | Following modifiers supported 310 | 311 | - *Day of Month / 3rd of 5 segments / 4th of 6+ segments:* 312 | - `L` stands for last day of month (eg: `L` could mean 29th for February in leap year) 313 | - `W` stands for closest week day (eg: `10W` is closest week days (MON-FRI) to 10th date) 314 | - *Day of Week / 5th of 5 segments / 6th of 6+ segments:* 315 | - `L` stands for last weekday of month (eg: `2L` is last tuesday) 316 | - `#` stands for nth day of week in the month (eg: `1#2` is second monday) 317 | 318 | --- 319 | ## License 320 | 321 | > © [MIT](./LICENSE) | 2021-2099, Jitendra Adhikari 322 | 323 | ## Credits 324 | 325 | This project is ported from [adhocore/cron-expr](https://github.com/adhocore/php-cron-expr) and 326 | release managed by [please](https://github.com/adhocore/please). 327 | 328 | --- 329 | ### Other projects 330 | 331 | My other golang projects you might find interesting and useful: 332 | 333 | - [**urlsh**](https://github.com/adhocore/urlsh) - URL shortener and bookmarker service with UI, API, Cache, Hits Counter and forwarder using postgres and redis in backend, bulma in frontend; has [web](https://urlssh.xyz) and cli client 334 | - [**fast**](https://github.com/adhocore/fast) - Check your internet speed with ease and comfort right from the terminal 335 | - [**goic**](https://github.com/adhocore/goic) - Go Open ID Connect, is OpenID connect client library for Golang, supports the Authorization Code Flow of OpenID Connect specification. 336 | - [**chin**](https://github.com/adhocore/chin) - A Go lang command line tool to show a spinner as user waits for some long running jobs to finish. 337 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | v0.2.7 2 | -------------------------------------------------------------------------------- /batch.go: -------------------------------------------------------------------------------- 1 | package gronx 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | ) 7 | 8 | // Expr represents an item in array for batch check 9 | type Expr struct { 10 | Err error 11 | Expr string 12 | Due bool 13 | } 14 | 15 | // BatchDue checks if multiple expressions are due for given time (or now). 16 | // It returns []Expr with filled in Due and Err values. 17 | func (g *Gronx) BatchDue(exprs []string, ref ...time.Time) []Expr { 18 | ref = append(ref, time.Now()) 19 | g.C.SetRef(ref[0]) 20 | 21 | var segs []string 22 | 23 | cache, batch := map[string]Expr{}, make([]Expr, len(exprs)) 24 | for i := range exprs { 25 | batch[i].Expr = exprs[i] 26 | segs, batch[i].Err = Segments(exprs[i]) 27 | key := strings.Join(segs, " ") 28 | if batch[i].Err != nil { 29 | cache[key] = batch[i] 30 | continue 31 | } 32 | 33 | if c, ok := cache[key]; ok { 34 | batch[i] = c 35 | batch[i].Expr = exprs[i] 36 | continue 37 | } 38 | 39 | due := true 40 | for pos, seg := range segs { 41 | if seg != "*" && seg != "?" { 42 | if due, batch[i].Err = g.C.CheckDue(seg, pos); !due || batch[i].Err != nil { 43 | break 44 | } 45 | } 46 | } 47 | batch[i].Due = due 48 | cache[key] = batch[i] 49 | } 50 | return batch 51 | } 52 | -------------------------------------------------------------------------------- /batch_test.go: -------------------------------------------------------------------------------- 1 | package gronx 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestBatch(t *testing.T) { 10 | gron := New() 11 | 12 | t.Run("batch no error", func(t *testing.T) { 13 | ref := time.Now() 14 | exprs := []string{"@everysecond", "* * * * * *", "* * * * * *"} 15 | exprs = append(exprs, fmt.Sprintf("* %d * * * * %d", ref.Minute(), ref.Year())) 16 | exprs = append(exprs, fmt.Sprintf("* * * * * * %d-%d", ref.Year()-1, ref.Year()+1)) 17 | 18 | for _, expr := range gron.BatchDue(exprs) { 19 | if expr.Err != nil { 20 | t.Errorf("%s error: %#v", expr.Expr, expr.Err) 21 | } 22 | if !expr.Due { 23 | t.Errorf("%s must be due", expr.Expr) 24 | } 25 | } 26 | }) 27 | 28 | t.Run("batch error", func(t *testing.T) { 29 | exprs := []string{"* * * *", "A B C D E F"} 30 | ref, _ := time.Parse(FullDateFormat, "2022-02-02 02:02:02") 31 | for _, expr := range gron.BatchDue(exprs, ref) { 32 | if expr.Err == nil { 33 | t.Errorf("%s expected error", expr.Expr) 34 | } 35 | if expr.Due { 36 | t.Errorf("%s must not be due when there is error", expr.Expr) 37 | } 38 | } 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /checker.go: -------------------------------------------------------------------------------- 1 | package gronx 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // Checker is interface for cron segment due check. 11 | type Checker interface { 12 | GetRef() time.Time 13 | SetRef(ref time.Time) 14 | CheckDue(segment string, pos int) (bool, error) 15 | } 16 | 17 | // SegmentChecker is factory implementation of Checker. 18 | type SegmentChecker struct { 19 | ref time.Time 20 | } 21 | 22 | // GetRef returns the current reference time 23 | func (c *SegmentChecker) GetRef() time.Time { 24 | return c.ref 25 | } 26 | 27 | // SetRef sets the reference time for which to check if a cron expression is due. 28 | func (c *SegmentChecker) SetRef(ref time.Time) { 29 | c.ref = ref 30 | } 31 | 32 | // CheckDue checks if the cron segment at given position is due. 33 | // It returns bool or error if any. 34 | func (c *SegmentChecker) CheckDue(segment string, pos int) (due bool, err error) { 35 | ref, last := c.GetRef(), -1 36 | val, loc := valueByPos(ref, pos), ref.Location() 37 | isMonthDay, isWeekDay := pos == 3, pos == 5 38 | 39 | for _, offset := range strings.Split(segment, ",") { 40 | mod := (isMonthDay || isWeekDay) && strings.ContainsAny(offset, "LW#") 41 | if due, err = c.isOffsetDue(offset, val, pos); due || (!mod && err != nil) { 42 | return 43 | } 44 | if !mod { 45 | continue 46 | } 47 | if last == -1 { 48 | last = time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, 1, 0).Add(-time.Second).Day() 49 | } 50 | if isMonthDay { 51 | due, err = isValidMonthDay(offset, last, ref) 52 | } else if isWeekDay { 53 | due, err = isValidWeekDay(offset, last, ref) 54 | } 55 | if due || err != nil { 56 | return due, err 57 | } 58 | } 59 | 60 | return false, nil 61 | } 62 | 63 | func (c *SegmentChecker) isOffsetDue(offset string, val, pos int) (bool, error) { 64 | if offset == "*" || offset == "?" { 65 | return true, nil 66 | } 67 | 68 | bounds, isWeekDay := boundsByPos(pos), pos == 5 69 | if strings.Contains(offset, "/") { 70 | return inStep(val, offset, bounds) 71 | } 72 | if strings.Contains(offset, "-") { 73 | if isWeekDay { 74 | offset = strings.Replace(offset, "7-", "0-", 1) 75 | } 76 | return inRange(val, offset, bounds) 77 | } 78 | 79 | nval, err := strconv.Atoi(offset) 80 | if err != nil { 81 | return false, err 82 | } 83 | 84 | if nval < bounds[0] || nval > bounds[1] { 85 | return false, fmt.Errorf("segment#%d: '%s' out of bounds(%d, %d)", pos, offset, bounds[0], bounds[1]) 86 | } 87 | 88 | if !isWeekDay && (val == 0 || nval == 0) { 89 | return nval == 0 && val == 0, nil 90 | } 91 | 92 | return nval == val || (isWeekDay && nval == 7 && val == 0), nil 93 | } 94 | 95 | func valueByPos(ref time.Time, pos int) (val int) { 96 | switch pos { 97 | case 0: 98 | val = ref.Second() 99 | case 1: 100 | val = ref.Minute() 101 | case 2: 102 | val = ref.Hour() 103 | case 3: 104 | val = ref.Day() 105 | case 4: 106 | val = int(ref.Month()) 107 | case 5: 108 | val = int(ref.Weekday()) 109 | case 6: 110 | val = ref.Year() 111 | } 112 | return 113 | } 114 | 115 | func boundsByPos(pos int) (bounds []int) { 116 | bounds = []int{0, 0} 117 | switch pos { 118 | case 0, 1: 119 | bounds = []int{0, 59} 120 | case 2: 121 | bounds = []int{0, 23} 122 | case 3: 123 | bounds = []int{1, 31} 124 | case 4: 125 | bounds = []int{1, 12} 126 | case 5: 127 | bounds = []int{0, 7} 128 | case 6: 129 | bounds = []int{0, 9999} 130 | } 131 | return 132 | } 133 | -------------------------------------------------------------------------------- /cmd/tasker/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "time" 9 | 10 | "github.com/adhocore/gronx/pkg/tasker" 11 | ) 12 | 13 | var exit = os.Exit 14 | var tick = time.Minute 15 | 16 | var opt tasker.Option 17 | var v bool 18 | 19 | // Version of tasker, injected in build 20 | var Version = "n/a" 21 | 22 | func init() { 23 | flag.StringVar(&opt.File, "file", "", "The task file in crontab format (without user)") 24 | flag.StringVar(&opt.Tz, "tz", "Local", "The timezone to use for tasks") 25 | flag.StringVar(&opt.Shell, "shell", tasker.Shell()[0], "The shell to use for running tasks") 26 | flag.StringVar(&opt.Out, "out", "", "The fullpath to file where output from tasks are sent to") 27 | flag.BoolVar(&opt.Verbose, "verbose", false, "The verbose mode outputs as much as possible") 28 | flag.Int64Var(&opt.Until, "until", 0, "The timeout for task daemon in minutes") 29 | flag.BoolVar(&v, "v", false, "Show version") 30 | } 31 | 32 | func main() { 33 | mustParseOption() 34 | 35 | taskr := tasker.New(opt) 36 | for _, task := range tasker.MustParseTaskfile(opt) { 37 | taskr.Task(task.Expr, taskr.Taskify(task.Cmd, opt)) 38 | } 39 | 40 | if opt.Until > 0 { 41 | taskr.Until(time.Duration(opt.Until) * tick) 42 | } 43 | 44 | taskr.Run() 45 | } 46 | 47 | func mustParseOption() { 48 | opt = tasker.Option{} 49 | flag.Parse() 50 | 51 | if v { 52 | fmt.Printf("v%s\n", Version) 53 | exit(0) 54 | } 55 | 56 | if opt.File == "" { 57 | flag.Usage() 58 | exit(1) 59 | } 60 | 61 | if _, err := os.Stat(opt.File); err != nil { 62 | log.Printf("can't read taskfile: %s", opt.File) 63 | exit(1) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /cmd/tasker/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/adhocore/gronx/pkg/tasker" 9 | ) 10 | 11 | func TestMustGetOption(t *testing.T) { 12 | old := os.Args 13 | exit = func (code int) {} 14 | t.Run("Main", func(t *testing.T) { 15 | expect := tasker.Option{File: "../../test/taskfile.txt", Out: "../../test/out.txt"} 16 | os.Args = append(old, "-verbose", "-file", expect.File, "-out", expect.Out) 17 | mustParseOption() 18 | if opt.File != expect.File { 19 | t.Errorf("file: expected %v, got %v", opt.File, expect.File) 20 | } 21 | if opt.Out != expect.Out { 22 | t.Errorf("out: expected %v, got %v", opt.Out, expect.Out) 23 | } 24 | 25 | t.Run("must parse option", func (t *testing.T) { 26 | os.Args = append(old, "-verbose", "-out", expect.Out) 27 | mustParseOption() 28 | if opt.File != "" { 29 | t.Error("opt.File must be empty "+opt.File) 30 | } 31 | 32 | os.Args = append(old, "-verbose", "-file", "invalid", "-out", expect.Out) 33 | mustParseOption() 34 | if opt.File != "invalid" { 35 | t.Error("opt.File must be invalid") 36 | } 37 | }) 38 | 39 | t.Run("run", func (t *testing.T) { 40 | tick = time.Second 41 | os.Args = append(old, "-verbose", "-file", expect.File, "-out", expect.Out, "-until", "2") 42 | main() 43 | }) 44 | 45 | os.Args = old 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/adhocore/gronx 2 | 3 | go 1.13 4 | -------------------------------------------------------------------------------- /gronx.go: -------------------------------------------------------------------------------- 1 | package gronx 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | var literals = strings.NewReplacer( 11 | "SUN", "0", "MON", "1", "TUE", "2", "WED", "3", "THU", "4", "FRI", "5", "SAT", "6", 12 | "JAN", "1", "FEB", "2", "MAR", "3", "APR", "4", "MAY", "5", "JUN", "6", "JUL", "7", 13 | "AUG", "8", "SEP", "9", "OCT", "10", "NOV", "11", "DEC", "12", 14 | ) 15 | 16 | var expressions = map[string]string{ 17 | "@yearly": "0 0 1 1 *", 18 | "@annually": "0 0 1 1 *", 19 | "@monthly": "0 0 1 * *", 20 | "@weekly": "0 0 * * 0", 21 | "@daily": "0 0 * * *", 22 | "@hourly": "0 * * * *", 23 | "@always": "* * * * *", 24 | "@5minutes": "*/5 * * * *", 25 | "@10minutes": "*/10 * * * *", 26 | "@15minutes": "*/15 * * * *", 27 | "@30minutes": "0,30 * * * *", 28 | 29 | "@everysecond": "* * * * * *", 30 | } 31 | 32 | // AddTag adds a new custom tag representing given expr 33 | func AddTag(tag, expr string) error { 34 | _, ok := expressions[tag] 35 | if ok { 36 | return errors.New("conflict tag") 37 | } 38 | 39 | segs, err := Segments(expr) 40 | if err != nil { 41 | return err 42 | } 43 | expr = strings.Join(segs, " ") 44 | 45 | expressions[tag] = expr 46 | return nil 47 | } 48 | 49 | // SpaceRe is regex for whitespace. 50 | var SpaceRe = regexp.MustCompile(`\s+`) 51 | var yearRe = regexp.MustCompile(`\d{4}`) 52 | 53 | func normalize(expr string) []string { 54 | expr = strings.Trim(expr, " \t") 55 | if e, ok := expressions[strings.ToLower(expr)]; ok { 56 | expr = e 57 | } 58 | 59 | expr = SpaceRe.ReplaceAllString(expr, " ") 60 | expr = literals.Replace(strings.ToUpper(expr)) 61 | 62 | return strings.Split(strings.ReplaceAll(expr, " ", " "), " ") 63 | } 64 | 65 | // Gronx is the main program. 66 | type Gronx struct { 67 | C Checker 68 | } 69 | 70 | // New initializes Gronx with factory defaults. 71 | func New() *Gronx { 72 | return &Gronx{&SegmentChecker{}} 73 | } 74 | 75 | // IsDue checks if cron expression is due for given reference time (or now). 76 | // It returns bool or error if any. 77 | func (g *Gronx) IsDue(expr string, ref ...time.Time) (bool, error) { 78 | if len(ref) == 0 { 79 | ref = append(ref, time.Now()) 80 | } 81 | g.C.SetRef(ref[0]) 82 | 83 | segs, err := Segments(expr) 84 | if err != nil { 85 | return false, err 86 | } 87 | 88 | return g.SegmentsDue(segs) 89 | } 90 | 91 | func (g *Gronx) isDue(expr string, ref time.Time) bool { 92 | due, err := g.IsDue(expr, ref) 93 | return err == nil && due 94 | } 95 | 96 | // Segments splits expr into array array of cron parts. 97 | // If expression contains 5 parts or 6th part is year like, it prepends a second. 98 | // It returns array or error. 99 | func Segments(expr string) ([]string, error) { 100 | segs := normalize(expr) 101 | slen := len(segs) 102 | if slen < 5 || slen > 7 { 103 | return []string{}, errors.New("expr should contain 5-7 segments separated by space") 104 | } 105 | 106 | // Prepend second if required 107 | prepend := slen == 5 || (slen == 6 && yearRe.MatchString(segs[5])) 108 | if prepend { 109 | segs = append([]string{"0"}, segs...) 110 | } 111 | 112 | return segs, nil 113 | } 114 | 115 | // SegmentsDue checks if all cron parts are due. 116 | // It returns bool. You should use IsDue(expr) instead. 117 | func (g *Gronx) SegmentsDue(segs []string) (bool, error) { 118 | skipMonthDayCheck := false 119 | for i := 0; i < len(segs); i++ { 120 | pos := len(segs) - 1 - i 121 | seg := segs[pos] 122 | isMonthDay, isWeekday := pos == 3, pos == 5 123 | 124 | if seg == "*" || seg == "?" { 125 | continue 126 | } 127 | 128 | if isMonthDay && skipMonthDayCheck { 129 | continue 130 | } 131 | 132 | if isWeekday { 133 | monthDaySeg := segs[3] 134 | intersect := strings.Index(seg, "*/") == 0 || strings.Index(monthDaySeg, "*") == 0 || monthDaySeg == "?" 135 | 136 | if !intersect { 137 | due, err := g.C.CheckDue(seg, pos) 138 | if err != nil { 139 | return false, err 140 | } 141 | 142 | monthDayDue, err := g.C.CheckDue(monthDaySeg, 3) 143 | if due || monthDayDue { 144 | skipMonthDayCheck = true 145 | continue 146 | } 147 | 148 | if err != nil { 149 | return false, err 150 | } 151 | } 152 | } 153 | 154 | if due, err := g.C.CheckDue(seg, pos); !due { 155 | return due, err 156 | } 157 | } 158 | 159 | return true, nil 160 | } 161 | 162 | // IsValid checks if cron expression is valid. 163 | // It returns bool. 164 | func (g *Gronx) IsValid(expr string) bool { return IsValid(expr) } 165 | 166 | // checker for validity 167 | var checker = &SegmentChecker{ref: time.Now()} 168 | 169 | // IsValid checks if cron expression is valid. 170 | // It returns bool. 171 | func IsValid(expr string) bool { 172 | segs, err := Segments(expr) 173 | if err != nil { 174 | return false 175 | } 176 | 177 | // First check syntax without time dependency 178 | if !isSyntaxValid(segs) { 179 | return false 180 | } 181 | 182 | // Then check with time dependency 183 | for pos, seg := range segs { 184 | if _, err := checker.CheckDue(seg, pos); err != nil { 185 | return false 186 | } 187 | } 188 | 189 | return true 190 | } 191 | 192 | // isSyntaxValid checks if the cron segments are syntactically valid without time dependency. 193 | // It returns bool. 194 | func isSyntaxValid(segs []string) bool { 195 | for _, seg := range segs { 196 | // Check for empty segments 197 | if seg == "" { 198 | return false 199 | } 200 | 201 | // Split by comma to check each part 202 | parts := strings.Split(seg, ",") 203 | for _, part := range parts { 204 | // Check for empty parts 205 | if part == "" { 206 | return false 207 | } 208 | 209 | // Check for invalid characters 210 | if strings.ContainsAny(part, "*/") { 211 | // If contains /, must have a number after it 212 | if strings.Contains(part, "/") { 213 | parts := strings.Split(part, "/") 214 | if len(parts) != 2 || parts[1] == "" { 215 | return false 216 | } 217 | } 218 | } 219 | } 220 | } 221 | return true 222 | } 223 | -------------------------------------------------------------------------------- /gronx_test.go: -------------------------------------------------------------------------------- 1 | package gronx 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | type Case struct { 11 | Expr string `json:"expr"` 12 | Ref string `json:"ref"` 13 | Expect bool `json:"expect"` 14 | Next string `json:"next"` 15 | } 16 | 17 | func (test Case) run(t *testing.T, gron *Gronx) (bool, error) { 18 | if test.Ref == "" { 19 | return gron.IsDue(test.Expr) 20 | } 21 | 22 | ref, err := time.Parse(FullDateFormat, test.Ref) 23 | if err != nil { 24 | t.Errorf("can't parse date: %s", test.Ref) 25 | t.Fail() 26 | } 27 | 28 | return gron.IsDue(test.Expr, ref) 29 | } 30 | 31 | func TestNormalize(t *testing.T) { 32 | tests := map[string]string{ 33 | "* * *\t*\n*": "* * * * *", 34 | "* * * * * 2021": "* * * * * 2021", 35 | "@hourly": "0 * * * *", 36 | "0 0 JAN,feb * sun,MON": "0 0 1,2 * 0,1", 37 | } 38 | 39 | for expr, expect := range tests { 40 | t.Run("normalize "+expr, func(t *testing.T) { 41 | actual := strings.Join(normalize(expr), " ") 42 | 43 | if expect != actual { 44 | t.Errorf("expected %v, got %v", expect, actual) 45 | } 46 | }) 47 | } 48 | } 49 | 50 | func TestIsValid(t *testing.T) { 51 | gron := New() 52 | 53 | t.Run("is valid", func(t *testing.T) { 54 | if !gron.IsValid("5,10-20/4,55 * * * *") { 55 | t.Errorf("expected true, got false") 56 | } 57 | if !gron.IsValid("00 * * * *") { 58 | t.Errorf("expected true, got false") 59 | } 60 | if !gron.IsValid("* 00 * * *") { 61 | t.Errorf("expected true, got false") 62 | } 63 | if expr := "* * * * *"; IsValid(expr) != gron.IsValid(expr) { 64 | t.Error("IsValid func and method must return same") 65 | } 66 | }) 67 | 68 | t.Run("is not valid", func(t *testing.T) { 69 | if gron.IsValid("A-B * * * *") { 70 | t.Errorf("expected false, got true") 71 | } 72 | if gron.IsValid("60 * * * *") { 73 | t.Errorf("expected false, got true") 74 | } 75 | if gron.IsValid("* 30 * * *") { 76 | t.Errorf("expected false, got true") 77 | } 78 | if gron.IsValid("* * 99 * *") { 79 | t.Errorf("expected false, got true") 80 | } 81 | if gron.IsValid("* * * 13 *") { 82 | t.Errorf("expected false, got true") 83 | } 84 | if gron.IsValid("* * * * 8") { 85 | t.Errorf("expected false, got true") 86 | } 87 | 88 | if gron.IsValid("60-65 * * * *") { 89 | t.Errorf("expected false, got true") 90 | } 91 | if gron.IsValid("* 24-28/2 * * *") { 92 | t.Errorf("expected false, got true") 93 | } 94 | if gron.IsValid("* * * *") { 95 | t.Errorf("expected false, got true") 96 | } 97 | if gron.IsValid("0-0/-005 * * * *") { 98 | t.Errorf("expected true, got false") 99 | } 100 | }) 101 | 102 | t.Run("sensitivity to reference time", func(t *testing.T) { 103 | originalRef := checker.ref 104 | defer func() { checker.ref = originalRef }() 105 | 106 | expr := "*/15, * * * *" 107 | moments := []time.Time{ 108 | time.Date(2025, 4, 29, 12, 13, 0, 0, time.UTC), 109 | time.Date(2025, 4, 29, 12, 14, 0, 0, time.UTC), 110 | time.Date(2025, 4, 29, 12, 15, 0, 0, time.UTC), 111 | time.Date(2025, 4, 29, 12, 16, 0, 0, time.UTC), 112 | time.Date(2025, 4, 29, 12, 17, 0, 0, time.UTC), 113 | } 114 | 115 | for _, moment := range moments { 116 | checker.ref = moment 117 | 118 | if gron.IsValid(expr) { 119 | t.Errorf("expected false, got true at %v", moment) 120 | } 121 | } 122 | 123 | }) 124 | } 125 | 126 | func TestAddTag(t *testing.T) { 127 | t.Run("add good tag", func(t *testing.T) { 128 | err := AddTag("@2s", "*/2 * * * * *") 129 | if err != nil { 130 | t.Error("expected nil, got err") 131 | } 132 | 133 | expr, ok := expressions["@2s"] 134 | if !ok { 135 | t.Error("expected true, got false") 136 | } 137 | 138 | if expr != "*/2 * * * * *" { 139 | t.Error("expected */2 * * * * *") 140 | } 141 | }) 142 | 143 | t.Run("add conflict tag", func(t *testing.T) { 144 | err := AddTag("@2s", "*/2 * * * * *") 145 | if err == nil { 146 | t.Error("expected err, got nil") 147 | } 148 | }) 149 | 150 | t.Run("add wrong tag", func(t *testing.T) { 151 | err := AddTag("@3s", "* * * *") 152 | if err == nil { 153 | t.Error("expected err, got nil") 154 | } 155 | }) 156 | } 157 | 158 | func TestIsDue(t *testing.T) { 159 | gron := New() 160 | 161 | t.Run("seconds precision", func(t *testing.T) { 162 | expr := "*/2 * * * * *" 163 | ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:04") 164 | due, _ := gron.IsDue(expr, ref) 165 | if !due { 166 | t.Errorf("%s should be due on %s", expr, ref) 167 | } 168 | 169 | due, _ = gron.IsDue(expr, ref.Add(time.Second)) 170 | if due { 171 | t.Errorf("%s should be due on %s", expr, ref) 172 | } 173 | }) 174 | 175 | for i, test := range testcases() { 176 | t.Run(fmt.Sprintf("is due #%d=%s", i, test.Expr), func(t *testing.T) { 177 | actual, _ := test.run(t, gron) 178 | 179 | if actual != test.Expect { 180 | t.Errorf("expected %v, got %v", test.Expect, actual) 181 | } 182 | }) 183 | } 184 | 185 | for i, test := range errcases() { 186 | t.Run(fmt.Sprintf("is due err #%d=%s", i, test.Expr), func(t *testing.T) { 187 | actual, err := test.run(t, gron) 188 | 189 | if actual != test.Expect { 190 | t.Errorf("expected %v, got %v", test.Expect, actual) 191 | } 192 | if err == nil { 193 | t.Errorf("expected error, got nil") 194 | } 195 | }) 196 | } 197 | } 198 | 199 | func TestValueByPos(t *testing.T) { 200 | t.Run("valueByPos 7", func(t *testing.T) { 201 | if actual := valueByPos(time.Now(), 7); actual != 0 { 202 | t.Errorf("expected 0, got %v", actual) 203 | } 204 | }) 205 | } 206 | 207 | func testcases() []Case { 208 | return []Case{ 209 | {"@always", "2021-04-19 12:54:00", true, "2021-04-19 12:55:00"}, 210 | {"* * * * * 2018", "2022-01-02 15:04:00", false, "err"}, 211 | {"* * * * * 2018", "2021-04-19 12:54:00", false, "err"}, 212 | {"@5minutes", "2017-05-10 02:30:00", true, "2017-05-10 02:35:00"}, 213 | {"* * 7W * *", "2017-10-15 20:00:00", false, "2017-11-07 00:00:00"}, 214 | {"*/2 */2 * * *", "2015-08-10 21:47:00", false, "2015-08-10 22:00:00"}, 215 | {"* * * * *", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, 216 | {"* * * * * ", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, 217 | {"* * * * *", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, 218 | {"* * * * *", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, 219 | {"* * * * *", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, 220 | {"* 20,21,22 * * *", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, 221 | {"* 20,22 * * *", "2015-08-10 21:50:00", false, "2015-08-10 22:00:00"}, 222 | {"* 5,21-22 * * *", "2015-08-10 21:50:00", true, "2015-08-10 21:51:00"}, 223 | {"7-9 * */9 * *", "2015-08-10 22:02:00", false, "2015-08-10 22:07:00"}, 224 | {"7-9 * */9 * *", "2015-08-11 22:02:00", false, "2015-08-19 00:07:00"}, 225 | {"1 * * * 7", "2015-08-10 21:47:00", false, "2015-08-16 00:01:00"}, 226 | {"47 21 * * *", "2015-08-10 21:47:00", true, "2015-08-11 21:47:00"}, 227 | {"00 * * * *", "2023-07-21 12:30:00", false, "2023-07-21 13:00:00"}, 228 | {"0 00 * * *", "2023-07-21 12:30:00", false, "2023-07-22 00:00:00"}, 229 | {"0 000 * * *", "2023-07-21 12:30:00", false, "2023-07-22 00:00:00"}, 230 | {"* * * * 0", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, 231 | {"* * * * 7", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, 232 | {"* * * * 1", "2011-06-15 23:09:00", false, "2011-06-20 00:00:00"}, 233 | {"0 0 * * MON,SUN", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, 234 | {"0 0 * * 1,7", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, 235 | {"0 0 * * 0-4", "2011-06-15 23:09:00", false, "2011-06-16 00:00:00"}, 236 | {"0 0 * * 7-4", "2011-06-15 23:09:00", false, "2011-06-16 00:00:00"}, 237 | {"0 0 * * 4-7", "2011-06-15 23:09:00", false, "2011-06-16 00:00:00"}, 238 | {"0 0 * * 7-3", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, 239 | {"0 0 * * 3-7", "2011-06-15 23:09:00", false, "2011-06-16 00:00:00"}, 240 | {"0 0 * * 3-7", "2011-06-18 23:09:00", false, "2011-06-22 00:00:00"}, 241 | {"0 0 * * 2-7", "2011-06-20 23:09:00", false, "2011-06-21 00:00:00"}, 242 | {"0 0 * * 0,2-6", "2011-06-20 23:09:00", false, "2011-06-21 00:00:00"}, 243 | {"0 0 * * 2-7", "2011-06-18 23:09:00", false, "2011-06-21 00:00:00"}, 244 | {"0 0 * * 4-7", "2011-07-19 00:00:00", false, "2011-07-21 00:00:00"}, 245 | {"0-12/4 * * * *", "2011-06-20 12:04:00", true, "2011-06-20 12:08:00"}, 246 | {"0-10/2 * * * *", "2011-06-20 12:12:00", false, "2011-06-20 13:00:00"}, 247 | {"4-59/2 * * * *", "2011-06-20 12:04:00", true, "2011-06-20 12:06:00"}, 248 | {"4-59/2 * * * *", "2011-06-20 12:06:00", true, "2011-06-20 12:08:00"}, 249 | {"4-59/3 * * * *", "2011-06-20 12:06:00", false, "2011-06-20 12:07:00"}, 250 | {"0 0 * * 0,2-6", "2011-06-20 23:09:00", false, "2011-06-21 00:00:00"}, 251 | {"0 0 1 1 0", "2011-06-15 23:09:00", false, "2012-01-01 00:00:00"}, 252 | {"0 0 1 JAN 0", "2011-06-15 23:09:00", false, "2012-01-01 00:00:00"}, 253 | {"0 0 1 * 0", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, 254 | {"0 0 L * *", "2011-07-15 00:00:00", false, "2011-07-31 00:00:00"}, 255 | {"0 0 2W * *", "2011-07-01 00:00:00", true, "2011-08-02 00:00:00"}, 256 | {"0 0 1W * *", "2011-05-01 00:00:00", false, "2011-05-02 00:00:00"}, 257 | {"0 0 1W * *", "2011-07-01 00:00:00", true, "2011-08-01 00:00:00"}, 258 | {"0 0 3W * *", "2011-07-01 00:00:00", false, "2011-07-04 00:00:00"}, 259 | {"0 0 16W * *", "2011-07-01 00:00:00", false, "2011-07-15 00:00:00"}, 260 | {"0 0 28W * *", "2011-07-01 00:00:00", false, "2011-07-28 00:00:00"}, 261 | {"0 0 30W * *", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"}, 262 | // {"0 0 31W * *", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"}, 263 | {"* * * * * 2012", "2011-05-01 00:00:00", false, "2012-01-01 00:00:00"}, 264 | {"* * * * 5L", "2011-07-01 00:00:00", false, "2011-07-29 00:00:00"}, 265 | {"* * * * 6L", "2011-07-01 00:00:00", false, "2011-07-30 00:00:00"}, 266 | {"* * * * 7L", "2011-07-01 00:00:00", false, "2011-07-31 00:00:00"}, 267 | {"* * * * 1L", "2011-07-24 00:00:00", false, "2011-07-25 00:00:00"}, 268 | {"* * * * TUEL", "2011-07-24 00:00:00", false, "2011-07-26 00:00:00"}, 269 | {"* * * 1 5L", "2011-12-25 00:00:00", false, "2012-01-27 00:00:00"}, 270 | {"* * * * 5#2", "2011-07-01 00:00:00", false, "2011-07-08 00:00:00"}, 271 | {"* * * * 5#1", "2011-07-01 00:00:00", true, "2011-07-01 00:01:00"}, 272 | {"* * * * 3#4", "2011-07-01 00:00:00", false, "2011-07-27 00:00:00"}, 273 | {"0 0 * * 1#1", "2009-10-23 00:00:00", false, "2009-11-02 00:00:00"}, 274 | {"0 0 * * 1#1", "2009-11-23 00:00:00", false, "2009-12-07 00:00:00"}, 275 | {"5/0 * * * *", "2021-04-19 12:54:00", false, "2018-08-13 00:25:00"}, 276 | {"5/20 * * * *", "2018-08-13 00:24:00", false, "2018-08-13 00:25:00"}, 277 | {"5/20 * * * *", "2018-08-13 00:45:00", true, "2018-08-13 01:05:00"}, 278 | {"5-11/4 * * * *", "2018-08-13 00:03:00", false, "2018-08-13 00:05:00"}, 279 | {"0 0 L * 0", "2011-06-15 23:09:00", false, "2011-06-19 00:00:00"}, 280 | {"3-59/15 6-12 */15 1 2-5", "2017-01-08 00:00:00", false, "2017-01-31 06:03:00"}, 281 | {"* * * * MON-FRI", "2017-01-08 00:00:00", false, "2017-01-09 00:00:00"}, 282 | {"* * * * TUE", "2017-01-08 00:00:00", false, "2017-01-10 00:00:00"}, 283 | {"0 1 15 JUL mon,Wed,FRi", "2019-11-14 00:00:00", false, "2020-07-01 01:00:00"}, 284 | {"0 1 15 jul mon,Wed,FRi", "2019-11-14 00:00:00", false, "2020-07-01 01:00:00"}, 285 | {"1 * 2 7 5-7", "2020-07-02 00:00:00", false, "2020-07-02 00:01:00"}, 286 | {"@weekly", "2019-11-14 00:00:00", false, "2019-11-17 00:00:00"}, 287 | {"@weekly", "2019-11-14 00:00:00", false, "2019-11-17 00:00:00"}, 288 | {"@weekly", "2019-11-14 00:00:00", false, "2019-11-17 00:00:00"}, 289 | {"0 12 * * ?", "2020-08-20 00:00:00", false, "2020-08-20 12:00:00"}, 290 | {"0 12 ? * *", "2020-08-20 00:00:00", false, "2020-08-20 12:00:00"}, 291 | {"* ? * ? * *", "2020-08-20 00:00:00", true, "2020-08-20 00:00:01"}, 292 | {"* * ? * * * */2", "2021-08-20 00:00:00", false, "2022-01-01 00:00:00"}, 293 | {"* * * * * * *", "2021-08-20 00:00:00", true, "2021-08-20 00:00:01"}, 294 | {"* * * * * * 2023-2099", "2021-08-20 00:00:00", false, "2023-01-01 00:00:00"}, 295 | {"30 9 L */3 *", "2023-04-23 09:30:00", false, "2023-04-30 09:30:00"}, 296 | {"30 9 L */3 *", "2023-05-01 09:30:00", false, "2023-07-31 09:30:00"}, 297 | {"0 * * * * * */2", "2019-05-01 09:30:00", false, "2020-01-01 00:00:00"}, 298 | {"0/4 * * * *", "2019-05-01 09:31:00", false, "2019-05-01 09:32:00"}, 299 | } 300 | } 301 | 302 | func errcases() []Case { 303 | return []Case{ 304 | {"* * * *", "", false, ""}, 305 | {"* * * * * * * *", "", false, ""}, 306 | {"- * * * *", "2011-07-01 00:01:00", false, ""}, 307 | {"/ * * * *", "2011-07-01 00:01:00", false, ""}, 308 | {"Z/Z * * * *", "2011-07-01 00:01:00", false, ""}, 309 | {"Z/0 * * * *", "2011-07-01 00:01:00", false, ""}, 310 | {"Z-10 * * * *", "2011-07-01 00:01:00", false, ""}, 311 | {"1-Z * * * *", "2011-07-01 00:01:00", false, ""}, 312 | {"1-Z/2 * * * *", "2011-07-01 00:01:00", false, ""}, 313 | {"Z-Z/2 * * * *", "2011-07-01 00:01:00", false, ""}, 314 | {"* * 0 * *", "2011-07-01 00:01:00", false, ""}, 315 | {"* * * W * *", "", false, ""}, 316 | {"* * * ZW * *", "", false, ""}, 317 | {"* * * * 4W", "2011-07-01 00:00:00", false, ""}, 318 | {"* * * 1L *", "2011-07-01 00:00:00", false, ""}, 319 | {"* * * * * ZL", "", false, ""}, 320 | {"* * * * * Z#", "", false, ""}, 321 | {"* * * * * 1#Z", "", false, ""}, 322 | {"* * W * L", "", false, ""}, 323 | {"* * 15 * 1#Z", "", false, ""}, 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /next.go: -------------------------------------------------------------------------------- 1 | package gronx 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // CronDateFormat is Y-m-d H:i (seconds are not significant) 13 | const CronDateFormat = "2006-01-02 15:04" 14 | 15 | // FullDateFormat is Y-m-d H:i:s (with seconds) 16 | const FullDateFormat = "2006-01-02 15:04:05" 17 | 18 | // NextTick gives next run time from now 19 | func NextTick(expr string, inclRefTime bool) (time.Time, error) { 20 | return NextTickAfter(expr, time.Now(), inclRefTime) 21 | } 22 | 23 | // NextTickAfter gives next run time from the provided time.Time 24 | func NextTickAfter(expr string, start time.Time, inclRefTime bool) (time.Time, error) { 25 | gron, next := New(), start.Truncate(time.Second) 26 | due, err := gron.IsDue(expr, start) 27 | if err != nil || (due && inclRefTime) { 28 | return start, err 29 | } 30 | 31 | segments, _ := Segments(expr) 32 | if len(segments) > 6 && isUnreachableYear(segments[6], next, false) { 33 | return next, fmt.Errorf("unreachable year segment: %s", segments[6]) 34 | } 35 | 36 | next, err = loop(gron, segments, next, inclRefTime, false) 37 | // Ignore superfluous err 38 | if err != nil && gron.isDue(expr, next) { 39 | err = nil 40 | } 41 | return next, err 42 | } 43 | 44 | func loop(gron *Gronx, segments []string, start time.Time, incl bool, reverse bool) (next time.Time, err error) { 45 | iter, next, bumped := 500, start, false 46 | over: 47 | for iter > 0 { 48 | iter-- 49 | skipMonthDayForIter := false 50 | for i := 0; i < len(segments); i++ { 51 | pos := len(segments) - 1 - i 52 | seg := segments[pos] 53 | isMonthDay, isWeekday := pos == 3, pos == 5 54 | 55 | if seg == "*" || seg == "?" { 56 | continue 57 | } 58 | 59 | if !isWeekday { 60 | if isMonthDay && skipMonthDayForIter { 61 | continue 62 | } 63 | if next, bumped, err = bumpUntilDue(gron.C, seg, pos, next, reverse); bumped { 64 | goto over 65 | } 66 | continue 67 | } 68 | // From here we process the weekday segment in case it is neither * nor ? 69 | 70 | monthDaySeg := segments[3] 71 | intersect := strings.Index(seg, "*/") == 0 || strings.Index(monthDaySeg, "*") == 0 || monthDaySeg == "?" 72 | 73 | nextForWeekDay := next 74 | nextForWeekDay, bumped, err = bumpUntilDue(gron.C, seg, pos, nextForWeekDay, reverse) 75 | if !bumped { 76 | // Weekday seg is specific and next is already at right weekday, so no need to process month day if union case 77 | next = nextForWeekDay 78 | if !intersect { 79 | skipMonthDayForIter = true 80 | } 81 | continue 82 | } 83 | // Weekday was bumped, so we need to check for month day 84 | 85 | if intersect { 86 | // We need intersection so we keep bumped weekday and go over 87 | next = nextForWeekDay 88 | goto over 89 | } 90 | // Month day seg is specific and a number/list/range, so we need to check and keep the closest to next 91 | 92 | nextForMonthDay := next 93 | nextForMonthDay, bumped, err = bumpUntilDue(gron.C, monthDaySeg, 3, nextForMonthDay, reverse) 94 | 95 | monthDayIsClosestToNextThanWeekDay := reverse && nextForMonthDay.After(nextForWeekDay) || 96 | !reverse && nextForMonthDay.Before(nextForWeekDay) 97 | 98 | if monthDayIsClosestToNextThanWeekDay { 99 | next = nextForMonthDay 100 | if !bumped { 101 | // Month day seg is specific and next is already at right month day, we can continue 102 | skipMonthDayForIter = true 103 | continue 104 | } 105 | } else { 106 | next = nextForWeekDay 107 | } 108 | goto over 109 | } 110 | 111 | if !incl && next.Format(FullDateFormat) == start.Format(FullDateFormat) { 112 | delta := time.Second 113 | if reverse { 114 | delta = -time.Second 115 | } 116 | next = next.Add(delta) 117 | continue 118 | } 119 | return 120 | } 121 | return start, errors.New("tried so hard") 122 | } 123 | 124 | var dashRe = regexp.MustCompile(`/.*$`) 125 | 126 | func isUnreachableYear(year string, ref time.Time, reverse bool) bool { 127 | if year == "*" || year == "?" { 128 | return false 129 | } 130 | 131 | edge := ref.Year() 132 | for _, offset := range strings.Split(year, ",") { 133 | if strings.Index(offset, "*/") == 0 || strings.Index(offset, "0/") == 0 { 134 | return false 135 | } 136 | for _, part := range strings.Split(dashRe.ReplaceAllString(offset, ""), "-") { 137 | val, err := strconv.Atoi(part) 138 | if err != nil || (!reverse && val >= edge) || (reverse && val <= edge) { 139 | return false 140 | } 141 | } 142 | } 143 | return true 144 | } 145 | 146 | var limit = map[int]int{0: 60, 1: 60, 2: 24, 3: 31, 4: 12, 5: 366, 6: 100} 147 | 148 | func bumpUntilDue(c Checker, segment string, pos int, ref time.Time, reverse bool) (time.Time, bool, error) { 149 | // 150 | iter := limit[pos] 151 | for iter > 0 { 152 | c.SetRef(ref) 153 | if ok, _ := c.CheckDue(segment, pos); ok { 154 | return ref, iter != limit[pos], nil 155 | } 156 | if reverse { 157 | ref = bumpReverse(ref, pos) 158 | } else { 159 | ref = bump(ref, pos) 160 | } 161 | iter-- 162 | } 163 | return ref, false, errors.New("tried so hard") 164 | } 165 | 166 | func bump(ref time.Time, pos int) time.Time { 167 | loc := ref.Location() 168 | 169 | switch pos { 170 | case 0: 171 | ref = ref.Add(time.Second) 172 | case 1: 173 | minTime := ref.Add(time.Minute) 174 | ref = time.Date(minTime.Year(), minTime.Month(), minTime.Day(), minTime.Hour(), minTime.Minute(), 0, 0, loc) 175 | case 2: 176 | hTime := ref.Add(time.Hour) 177 | ref = time.Date(hTime.Year(), hTime.Month(), hTime.Day(), hTime.Hour(), 0, 0, 0, loc) 178 | case 3, 5: 179 | dTime := ref.AddDate(0, 0, 1) 180 | ref = time.Date(dTime.Year(), dTime.Month(), dTime.Day(), 0, 0, 0, 0, loc) 181 | case 4: 182 | ref = time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, loc) 183 | ref = ref.AddDate(0, 1, 0) 184 | case 6: 185 | yTime := ref.AddDate(1, 0, 0) 186 | ref = time.Date(yTime.Year(), 1, 1, 0, 0, 0, 0, loc) 187 | } 188 | return ref 189 | } 190 | -------------------------------------------------------------------------------- /next_test.go: -------------------------------------------------------------------------------- 1 | package gronx 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestNextTick(t *testing.T) { 11 | exp := "* * * * * *" 12 | t.Run("next tick incl "+exp, func(t *testing.T) { 13 | now := time.Now().Format(FullDateFormat) 14 | next, _ := NextTick(exp, true) 15 | tick := next.Format(FullDateFormat) 16 | if now != tick { 17 | t.Errorf("expected %v, got %v", now, tick) 18 | } 19 | }) 20 | t.Run("next tick excl "+exp, func(t *testing.T) { 21 | expect := time.Now().Add(time.Second).Format(FullDateFormat) 22 | next, _ := NextTick(exp, false) 23 | tick := next.Format(FullDateFormat) 24 | if expect != tick { 25 | t.Errorf("expected %v, got %v", expect, tick) 26 | } 27 | }) 28 | } 29 | 30 | func TestNextTickAfter(t *testing.T) { 31 | t.Run("next run after", func(t *testing.T) { 32 | t.Run("seconds precision", func(t *testing.T) { 33 | ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02") 34 | next, _ := NextTickAfter("*/5 * * * * *", ref, false) 35 | if next.Format(FullDateFormat) != "2020-02-02 02:02:05" { 36 | t.Errorf("2020-02-02 02:02:02 next tick should be 2020-02-02 02:02:05") 37 | } 38 | }) 39 | 40 | for i, test := range testcases() { 41 | t.Run(fmt.Sprintf("next run after incl #%d: %s", i, test.Expr), func(t *testing.T) { 42 | ref, _ := time.Parse(FullDateFormat, test.Ref) 43 | if next, err := NextTickAfter(test.Expr, ref, true); err == nil { 44 | actual := next.Format(FullDateFormat) 45 | if test.Expect != (test.Ref == actual) { 46 | t.Errorf("[incl] expected %v, got %v", test.Ref, actual) 47 | } 48 | } 49 | }) 50 | } 51 | 52 | gron := New() 53 | for i, test := range testcases() { 54 | t.Run(fmt.Sprintf("next run after excl #%d: %s", i, test.Expr), func(t *testing.T) { 55 | ref, _ := time.Parse(FullDateFormat, test.Ref) 56 | next, err := NextTickAfter(test.Expr, ref, false) 57 | if err == nil { 58 | expect := test.Next 59 | if expect == "" { 60 | expect = test.Ref 61 | } 62 | actual := next.Format(FullDateFormat) 63 | if due, _ := gron.IsDue(test.Expr, next); !due { 64 | t.Errorf("[%s][%s] should be due on %v", test.Expr, test.Ref, next.Format(FullDateFormat)) 65 | } 66 | if expect != actual { 67 | t.Errorf("[%s][%s] expected %v, got %v", test.Expr, test.Ref, expect, actual) 68 | } 69 | } else { 70 | fmt.Println(test.Expr+" failed", err) 71 | } 72 | }) 73 | } 74 | }) 75 | } 76 | 77 | func TestIsUnreachableYearPrevTickBefore(t *testing.T) { 78 | now := time.Date(2024, time.November, 8, 22, 18, 16, 0, time.UTC) 79 | tests := []struct { 80 | name string 81 | cronExpr string 82 | expectedTime time.Time 83 | expectError bool 84 | }{ 85 | { 86 | // https://github.com/adhocore/gronx/issues/51 87 | name: "Current Year - Previous Tick", 88 | cronExpr: "30 15 4 11 * 2024", 89 | expectedTime: time.Date(2024, time.November, 4, 15, 30, 0, 0, time.UTC), 90 | expectError: false, 91 | }, 92 | { 93 | name: "Next Year - Previous Tick (Unreachable Year)", 94 | cronExpr: "30 15 4 11 * 2025", 95 | expectedTime: time.Time{}, // Error expected 96 | expectError: true, 97 | }, 98 | { 99 | name: "Previous Year - Previous Tick", 100 | cronExpr: "30 15 4 11 * 2023", 101 | expectedTime: time.Date(2023, time.November, 4, 15, 30, 0, 0, time.UTC), 102 | expectError: false, 103 | }, 104 | } 105 | 106 | for _, tc := range tests { 107 | t.Run(tc.name, func(t *testing.T) { 108 | actualTime, err := PrevTickBefore(tc.cronExpr, now, true) 109 | if tc.expectError { 110 | if err == nil || !strings.Contains(err.Error(), "unreachable year segment") { 111 | t.Errorf("expected unreachable year error, got: %v", err) 112 | } 113 | } else { 114 | if err != nil { 115 | t.Errorf("unexpected error: %v", err) 116 | } else if !actualTime.Equal(tc.expectedTime) { 117 | t.Errorf("expected previous tick to be %v, got %v", tc.expectedTime, actualTime) 118 | } 119 | } 120 | }) 121 | } 122 | } 123 | 124 | func TestIsUnreachableYearNextTickAfter(t *testing.T) { 125 | now := time.Date(2024, time.November, 8, 22, 18, 16, 0, time.UTC) 126 | tests := []struct { 127 | name string 128 | cronExpr string 129 | expectedTime time.Time 130 | expectError bool 131 | }{ 132 | { 133 | // https://github.com/adhocore/gronx/issues/53 134 | name: "Current Year - Next Tick", 135 | cronExpr: "30 15 31 12 * 2024", 136 | expectedTime: time.Date(2024, time.December, 31, 15, 30, 0, 0, time.UTC), 137 | expectError: false, 138 | }, 139 | { 140 | name: "Next Year - Next Tick", 141 | cronExpr: "30 15 31 12 * 2025", 142 | expectedTime: time.Date(2025, time.December, 31, 15, 30, 0, 0, time.UTC), 143 | expectError: false, 144 | }, 145 | { 146 | name: "Previous Year - Next Tick (Unreachable Year)", 147 | cronExpr: "30 15 31 12 * 2023", 148 | expectedTime: time.Time{}, // Error expected 149 | expectError: true, 150 | }, 151 | } 152 | 153 | for _, tc := range tests { 154 | t.Run(tc.name, func(t *testing.T) { 155 | actualTime, err := NextTickAfter(tc.cronExpr, now, false) 156 | if tc.expectError { 157 | if err == nil || !strings.Contains(err.Error(), "unreachable year segment") { 158 | t.Errorf("expected unreachable year error, got: %v", err) 159 | } 160 | } else { 161 | if err != nil { 162 | t.Errorf("unexpected error: %v", err) 163 | } else if !actualTime.Equal(tc.expectedTime) { 164 | t.Errorf("expected next tick to be %v, got %v", tc.expectedTime, actualTime) 165 | } 166 | } 167 | }) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /pkg/tasker/README.md: -------------------------------------------------------------------------------- 1 | # adhocore/gronx/pkg/tasker 2 | 3 | [![Latest Version](https://img.shields.io/github/release/adhocore/gronx.svg?style=flat-square)](https://github.com/adhocore/gronx/releases) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) 5 | [![Go Report](https://goreportcard.com/badge/github.com/adhocore/gronx)](https://goreportcard.com/report/github.com/adhocore/gronx) 6 | [![Test](https://github.com/adhocore/gronx/actions/workflows/test-action.yml/badge.svg)](https://github.com/adhocore/gronx/actions/workflows/test-action.yml) 7 | [![Donate](https://img.shields.io/badge/donate-paypal-blue.svg?style=flat-square)](https://www.paypal.me/ji10/50usd) 8 | [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Lightweight+fast+and+deps+free+cron+expression+parser+for+Golang&url=https://github.com/adhocore/gronx&hashtags=go,golang,parser,cron,cronexpr,cronparser) 9 | 10 | 11 | `tasker` is cron expression based task scheduler and/or daemon for programamtic usage in Golang (tested on v1.13 and above) or independent standalone usage. 12 | 13 | ## Installation 14 | 15 | ```sh 16 | go get -u github.com/adhocore/gronx/cmd/tasker 17 | ``` 18 | --- 19 | ## Usage 20 | ### Go Tasker 21 | 22 | Tasker is a task manager that can be programatically used in Golang applications. 23 | It runs as a daemon and and invokes tasks scheduled with cron expression: 24 | 25 | ```go 26 | package main 27 | 28 | import ( 29 | "context" 30 | "time" 31 | 32 | "github.com/adhocore/gronx/pkg/tasker" 33 | ) 34 | 35 | func main() { 36 | taskr := tasker.New(tasker.Option{ 37 | Verbose: true, 38 | // optional: defaults to local 39 | Tz: "Asia/Bangkok", 40 | // optional: defaults to stderr log stream 41 | Out: "/full/path/to/output-file", 42 | }) 43 | 44 | // add task to run every minute 45 | taskr.Task("* * * * *", func(ctx context.Context) (int, error) { 46 | // do something ... 47 | 48 | // then return exit code and error, for eg: if everything okay 49 | return 0, nil 50 | }).Task("*/5 * * * *", func(ctx context.Context) (int, error) { // every 5 minutes 51 | // you can also log the output to Out file as configured in Option above: 52 | taskr.Log.Printf("done something in %d s", 2) 53 | 54 | return 0, nil 55 | }) 56 | 57 | // run task without overlap, set concurrent flag to false: 58 | concurrent := false 59 | taskr.Task("* * * * * *", , tasker.Taskify("sleep 2", tasker.Option{}), concurrent) 60 | 61 | // every 10 minute with arbitrary command 62 | taskr.Task("@10minutes", taskr.Taskify("command --option val -- args", tasker.Option{Shell: "/bin/sh -c"})) 63 | 64 | // ... add more tasks 65 | 66 | // optionally if you want tasker to stop after 2 hour, pass the duration with Until(): 67 | taskr.Until(2 * time.Hour) 68 | 69 | // finally run the tasker, it ticks sharply on every minute and runs all the tasks due on that time! 70 | // it exits gracefully when ctrl+c is received making sure pending tasks are completed. 71 | taskr.Run() 72 | } 73 | ``` 74 | 75 | #### Concurrency 76 | 77 | By default the tasks can run concurrently i.e if previous run is still not finished 78 | but it is now due again, it will run again. 79 | If you want to run only one instance of a task at a time, set concurrent flag to false: 80 | 81 | ```go 82 | taskr := tasker.New(tasker.Option{}) 83 | 84 | concurrent := false 85 | expr, task := "* * * * * *", tasker.Taskify("php -r 'sleep(2);'") 86 | taskr.Task(expr, task, concurrent) 87 | ``` 88 | 89 | ### Task Daemon 90 | It can also be used as standalone task daemon instead of programmatic usage for Golang application. 91 | 92 | First, just install tasker command: 93 | ```sh 94 | go install github.com/adhocore/gronx/cmd/tasker@latest 95 | ``` 96 | 97 | Or you can also download latest prebuilt binary from [release](https://github.com/adhocore/gronx/releases/latest) for platform of your choice. 98 | 99 | Then prepare a taskfile ([example](https://github.com/adhocore/gronx/blob/main/test/taskfile.txt)) in crontab format 100 | (or can even point to existing crontab). 101 | > `user` is not supported: it is just cron expr followed by the command. 102 | 103 | Finally run the task daemon like so 104 | ``` 105 | tasker -file path/to/taskfile 106 | ``` 107 | 108 | #### Version 109 | 110 | ```sh 111 | tasker -v 112 | ``` 113 | 114 | > You can pass more options to control the behavior of task daemon, see below. 115 | 116 | #### Tasker command options: 117 | ```txt 118 | -file string 119 | The task file in crontab format 120 | -out string 121 | The fullpath to file where output from tasks are sent to 122 | -shell string 123 | The shell to use for running tasks (default "/usr/bin/bash") 124 | -tz string 125 | The timezone to use for tasks (default "Local") 126 | -until int 127 | The timeout for task daemon in minutes 128 | -verbose 129 | The verbose mode outputs as much as possible 130 | ``` 131 | 132 | Examples: 133 | ```sh 134 | tasker -verbose -file path/to/taskfile -until 120 # run until next 120min (i.e 2hour) with all feedbacks echoed back 135 | tasker -verbose -file path/to/taskfile -out path/to/output # with all feedbacks echoed to the output file 136 | tasker -tz America/New_York -file path/to/taskfile -shell zsh # run all tasks using zsh shell based on NY timezone 137 | ``` 138 | 139 | > File extension of taskfile for (`-file` option) does not matter: can be any or none. 140 | > The directory for outfile (`-out` option) must exist, file is created by task daemon. 141 | 142 | > Same timezone applies for all tasks currently and it might support overriding timezone per task in future release. 143 | 144 | #### Notes on Windows 145 | In Windows if it doesn't find `bash.exe` or `git-bash.exe` it will use `powershell`. 146 | `powershell` may not be compatible with Unix flavored commands. Also to note: 147 | you can't do chaining with `cmd1 && cmd2` but rather `cmd1 ; cmd2`. 148 | 149 | --- 150 | ## Understanding Cron Expression 151 | 152 | Checkout [gronx](https://github.com/adhocore/gronx#cron-expression) docs on cron expression. 153 | 154 | --- 155 | ## License 156 | 157 | > © [MIT](https://github.com/adhocore/gronx/blob/main/LICENSE) | 2021-2099, Jitendra Adhikari 158 | 159 | ## Credits 160 | 161 | This project is ported from [adhocore/cron-expr](https://github.com/adhocore/php-cron-expr) and 162 | release managed by [please](https://github.com/adhocore/please). 163 | 164 | --- 165 | ### Other projects 166 | My other golang projects you might find interesting and useful: 167 | 168 | - [**urlsh**](https://github.com/adhocore/urlsh) - URL shortener and bookmarker service with UI, API, Cache, Hits Counter and forwarder using postgres and redis in backend, bulma in frontend; has [web](https://urlssh.xyz) and cli client 169 | - [**fast**](https://github.com/adhocore/fast) - Check your internet speed with ease and comfort right from the terminal 170 | -------------------------------------------------------------------------------- /pkg/tasker/parser.go: -------------------------------------------------------------------------------- 1 | package tasker 2 | 3 | import ( 4 | "bufio" 5 | "log" 6 | "os" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/adhocore/gronx" 11 | ) 12 | 13 | // MustParseTaskfile either parses taskfile from given Option. 14 | // It fails hard in case any error. 15 | func MustParseTaskfile(opts Option) []Task { 16 | file, err := os.Open(opts.File) 17 | if err != nil { 18 | log.Printf("[parser] can't open file: %s", opts.File) 19 | exit(1) 20 | } 21 | defer file.Close() 22 | 23 | lines := []string{} 24 | scan := bufio.NewScanner(file) 25 | for scan.Scan() { 26 | ln := strings.TrimLeft(scan.Text(), " \t") 27 | // Skip empty or comment 28 | if ln != "" && ln[0] != '#' { 29 | lines = append(lines, ln) 30 | } 31 | } 32 | 33 | if err := scan.Err(); err != nil { 34 | if len(lines) == 0 { 35 | log.Printf("[parser] error reading taskfile: %v", err) 36 | exit(1) 37 | } 38 | 39 | log.Println(err) 40 | } 41 | 42 | return linesToTasks(lines) 43 | } 44 | 45 | // var cronRe = regexp.MustCompile(`^((?:[^\s]+\s+){5,6}(?:\d{4})?)(?:\s+)?(.*)`) 46 | var aliasRe = regexp.MustCompile(`^(@(?:annually|yearly|monthly|weekly|daily|hourly|5minutes|10minutes|15minutes|30minutes|always|everysecond))(?:\s+)?(.*)`) 47 | var segRe = regexp.MustCompile(`(?i),|/\d+$|^\d+-\d+$|^([0-7]|sun|mon|tue|wed|thu|fri|sat)(L|W|#\d)?$|-([0-7]|sun|mon|tue|wed|thu|fri|sat)$|\d{4}`) 48 | 49 | func linesToTasks(lines []string) []Task { 50 | var tasks []Task 51 | 52 | gron := gronx.New() 53 | for _, line := range lines { 54 | var match []string 55 | if line[0] == '@' { 56 | match = aliasRe.FindStringSubmatch(line) 57 | } else { 58 | match = parseLine(line) 59 | } 60 | 61 | if len(match) > 2 && gron.IsValid(match[1]) { 62 | tasks = append(tasks, Task{strings.Trim(match[1], " \t"), match[2]}) 63 | continue 64 | } 65 | 66 | log.Printf("[parser] can't parse cron expr: %s", line) 67 | } 68 | 69 | return tasks 70 | } 71 | 72 | func parseLine(line string) (match []string) { 73 | wasWs, expr, cmd := false, "", "" 74 | i, nseg, llen := 0, 0, len(line)-1 75 | match = append(match, line) 76 | 77 | for ; i < llen && nseg <= 7; i++ { 78 | isWs := strings.ContainsAny(line[i:i+1], "\t ") 79 | if nseg >= 5 { 80 | seg, ws := "", line[i-1:i] 81 | for i < llen && !strings.ContainsAny(line[i:i+1], "\t ") { 82 | i, seg = i+1, seg+line[i:i+1] 83 | } 84 | if isCronPart(seg) { 85 | expr, nseg = expr+ws+seg, nseg+1 86 | } else if seg != "" { 87 | cmd += seg 88 | break 89 | } 90 | } else { 91 | expr += line[i : i+1] 92 | } 93 | if isWs && !wasWs { 94 | nseg++ 95 | } 96 | wasWs = isWs 97 | } 98 | cmd += line[i:] 99 | if nseg >= 5 && strings.TrimSpace(cmd) != "" { 100 | match = append(match, expr, cmd) 101 | } 102 | return 103 | } 104 | 105 | func isCronPart(seg string) bool { 106 | return seg != "" && seg[0] != '/' && (seg[0] == '*' || seg[0] == '?' || segRe.MatchString(seg)) 107 | } 108 | -------------------------------------------------------------------------------- /pkg/tasker/parser_test.go: -------------------------------------------------------------------------------- 1 | package tasker 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestMustParseTaskfile(t *testing.T) { 9 | exit = func(code int) {} 10 | t.Run("MustParseTaskfile", func(t *testing.T) { 11 | tasks := MustParseTaskfile(Option{File: "../../test/taskfile.txt"}) 12 | if len(tasks) != 8 { 13 | t.Errorf("should have 8 tasks, got %d", len(tasks)) 14 | } 15 | 16 | if tasks[0].Expr != "*/1 0/1 * * *" { 17 | t.Errorf("expected '*/1 0/1 * * *', got %s", tasks[0].Expr) 18 | } 19 | 20 | if tasks[2].Cmd != "echo '[task 3] @always' > test/task3.out" { 21 | t.Errorf("expected `echo '[task 3] @always' > test/task3.out`, got %s", tasks[2].Cmd) 22 | } 23 | 24 | t.Run("complex file - seconds precision", func(t *testing.T) { 25 | tasks := MustParseTaskfile(Option{File: "../../test/taskfile-complex.txt"}) 26 | if len(tasks) != 13 { 27 | t.Errorf("should have 13 tasks, got %d", len(tasks)) 28 | } 29 | for i, task := range tasks { 30 | if !strings.HasPrefix(task.Cmd, `echo "`) { 31 | t.Errorf("invalid cmd at %d [%s]: %s", i, task.Expr, task.Cmd) 32 | } 33 | } 34 | }) 35 | 36 | t.Run("must parse - no file", func(t *testing.T) { 37 | tasks := MustParseTaskfile(Option{File: "../../test/taskfile.txtx"}) 38 | if len(tasks) != 0 { 39 | t.Errorf("should have 0 tasks, got %d", len(tasks)) 40 | } 41 | }) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/tasker/tasker.go: -------------------------------------------------------------------------------- 1 | package tasker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "os/signal" 10 | "path/filepath" 11 | "reflect" 12 | "strings" 13 | "sync" 14 | "sync/atomic" 15 | "syscall" 16 | "time" 17 | 18 | "github.com/adhocore/gronx" 19 | ) 20 | 21 | // Option is the config options for Tasker. 22 | type Option struct { 23 | File string 24 | Tz string 25 | Shell string 26 | Out string 27 | Until int64 28 | Verbose bool 29 | } 30 | 31 | // TaskFunc is the actual task handler. 32 | type TaskFunc func(ctx context.Context) (int, error) 33 | 34 | // Task wraps a cron expr and its' command. 35 | type Task struct { 36 | Expr string 37 | Cmd string 38 | } 39 | 40 | // Tasker is the task manager. 41 | type Tasker struct { 42 | until time.Time 43 | ctx context.Context 44 | loc *time.Location 45 | gron *gronx.Gronx 46 | Log *log.Logger 47 | exprs map[string][]string 48 | tasks map[string]TaskFunc 49 | mutex map[string]*uint32 50 | ctxCancel context.CancelFunc 51 | wg sync.WaitGroup 52 | verbose bool 53 | running bool 54 | timeout bool 55 | abort bool 56 | } 57 | 58 | type result struct { 59 | err error 60 | ref string 61 | code int 62 | } 63 | 64 | var exit = os.Exit 65 | 66 | // New inits a task manager. 67 | // It returns Tasker. 68 | func New(opt Option) *Tasker { 69 | gron := gronx.New() 70 | tasks := make(map[string]TaskFunc) 71 | exprs := make(map[string][]string) 72 | 73 | if opt.Tz == "" { 74 | opt.Tz = "Local" 75 | } 76 | 77 | loc, err := time.LoadLocation(opt.Tz) 78 | if err != nil { 79 | log.Printf("invalid tz location: %s", opt.Tz) 80 | exit(1) 81 | } 82 | 83 | logger := log.New(os.Stderr, "", log.LstdFlags) 84 | if opt.Out != "" { 85 | if _, err := os.Stat(filepath.Dir(opt.Out)); err != nil { 86 | log.Printf("output dir does not exist: %s", filepath.Base(opt.Out)) 87 | exit(1) 88 | } 89 | 90 | file, err := os.OpenFile(opt.Out, os.O_CREATE|os.O_WRONLY, 0777) 91 | if err != nil { 92 | log.Printf("can't open output file: %s", opt.Out) 93 | exit(1) 94 | } 95 | 96 | logger = log.New(file, "", log.LstdFlags) 97 | } 98 | 99 | ctx, cancel := context.WithCancel(context.Background()) 100 | return &Tasker{ 101 | Log: logger, 102 | loc: loc, 103 | gron: gron, 104 | exprs: exprs, 105 | tasks: tasks, 106 | verbose: opt.Verbose, 107 | ctx: ctx, 108 | ctxCancel: cancel, 109 | } 110 | } 111 | 112 | // WithContext adds a parent context to the Tasker struct 113 | // and begins the abort when Done is received 114 | func (t *Tasker) WithContext(ctx context.Context) *Tasker { 115 | t.ctx, t.ctxCancel = context.WithCancel(ctx) 116 | return t 117 | } 118 | 119 | // Shell gives a pair of shell and arg. 120 | // It returns array of string. 121 | func Shell(shell ...string) []string { 122 | if os.PathSeparator == '\\' { 123 | shell = append(shell, "git-bash.exe -c", "bash.exe -c", "powershell.exe -Command") 124 | } else { 125 | shell = append(shell, "bash -c", "sh -c", "zsh -c") 126 | } 127 | 128 | for _, sh := range shell { 129 | arg := "-c" 130 | cmd := strings.Split(sh, " -") 131 | if len(cmd) > 1 { 132 | arg = "-" + cmd[1] 133 | } 134 | if exc, err := exec.LookPath(cmd[0]); err == nil { 135 | return []string{exc, arg} 136 | } 137 | } 138 | 139 | return []string{"/bin/sh", "-c"} 140 | } 141 | 142 | const taskIDFormat = "[%s][#%d]" 143 | 144 | // Task appends new task handler for given cron expr. 145 | // It returns Tasker (itself) for fluency and bails if expr is invalid. 146 | func (t *Tasker) Task(expr string, task TaskFunc, concurrent ...bool) *Tasker { 147 | segs, err := gronx.Segments(expr) 148 | if err != nil { 149 | log.Fatalf("invalid cron expr: %+v", err) 150 | } 151 | 152 | concurrent = append(concurrent, true) 153 | old, expr := gronx.SpaceRe.ReplaceAllString(expr, " "), strings.Join(segs, " ") 154 | if _, ok := t.exprs[expr]; !ok { 155 | if !t.gron.IsValid(expr) { 156 | log.Fatalf("invalid cron expr: %+v", err) 157 | } 158 | 159 | t.exprs[expr] = []string{} 160 | } 161 | 162 | ref := fmt.Sprintf(taskIDFormat, old, len(t.exprs[expr])+1) 163 | 164 | t.exprs[expr] = append(t.exprs[expr], ref) 165 | t.tasks[ref] = task 166 | 167 | if !concurrent[0] { 168 | if len(t.mutex) == 0 { 169 | t.mutex = make(map[string]*uint32) 170 | } 171 | t.mutex[ref] = new(uint32) 172 | } 173 | 174 | return t 175 | } 176 | 177 | // Until sets the cutoff time until which the tasker runs. 178 | // It returns itself for fluency. 179 | func (t *Tasker) Until(until interface{}) *Tasker { 180 | switch until := until.(type) { 181 | case time.Duration: 182 | t.until = t.now().Add(until) 183 | case time.Time: 184 | t.until = until 185 | default: 186 | log.Printf("until must be time.Duration or time.Time, got: %v", reflect.TypeOf(until)) 187 | exit(1) 188 | } 189 | 190 | return t 191 | } 192 | 193 | func (t *Tasker) now() time.Time { 194 | return time.Now().In(t.loc) 195 | } 196 | 197 | // Run runs the task manager. 198 | func (t *Tasker) Run() { 199 | t.doSetup() 200 | t.running = true 201 | 202 | first := true 203 | for !t.abort && !t.timeout { 204 | ref, willTime := t.tickTimer(first) 205 | if t.timeout || t.abort { 206 | break 207 | } 208 | 209 | tasks := make(map[string]TaskFunc) 210 | t.gron.C.SetRef(ref) 211 | for expr, refs := range t.exprs { 212 | if due, _ := t.gron.SegmentsDue(strings.Split(expr, " ")); !due { 213 | continue 214 | } 215 | 216 | for _, ref := range refs { 217 | tasks[ref] = t.tasks[ref] 218 | } 219 | } 220 | 221 | if len(tasks) > 0 { 222 | t.runTasks(tasks) 223 | } 224 | 225 | first = false 226 | t.timeout = willTime 227 | } 228 | 229 | t.wait() 230 | t.running = false 231 | } 232 | 233 | // Running tells if tasker is up and running 234 | func (t *Tasker) Running() bool { 235 | return t.running && !t.abort && !t.timeout 236 | } 237 | 238 | // Stop the task manager. 239 | func (t *Tasker) Stop() { 240 | t.stop() 241 | } 242 | 243 | func (t *Tasker) stop() { 244 | t.ctxCancel() 245 | t.abort = true 246 | } 247 | 248 | var dateFormat = "2006/01/02 15:04:05" 249 | 250 | func (t *Tasker) doSetup() { 251 | if len(t.tasks) == 0 { 252 | t.Log.Fatal("[tasker] no tasks available") 253 | } 254 | if !t.until.IsZero() && t.verbose { 255 | if t.until.Before(t.now()) { 256 | log.Fatalf("[tasker] timeout must be in future") 257 | } 258 | t.Log.Printf("[tasker] final tick on or before %s", t.until.Format(dateFormat)) 259 | } 260 | 261 | // If we have seconds precision tickSec should be 1 262 | for expr := range t.exprs { 263 | if expr[0:2] != "0 " { 264 | tickSec = 1 265 | break 266 | } 267 | } 268 | 269 | sig := make(chan os.Signal, 1) 270 | signal.Notify(sig, os.Interrupt, syscall.SIGTERM) 271 | 272 | go func() { 273 | select { 274 | case <-sig: 275 | case <-t.ctx.Done(): 276 | if t.verbose { 277 | t.Log.Printf("[tasker] received signal on context.Done, aborting") 278 | } 279 | } 280 | 281 | t.stop() 282 | }() 283 | } 284 | 285 | var tickSec = 60 286 | 287 | func (t *Tasker) tickTimer(first bool) (time.Time, bool) { 288 | now, timed, willTime := t.now(), !t.until.IsZero(), false 289 | if t.timeout || t.abort { 290 | return now, willTime 291 | } 292 | 293 | wait := tickSec - now.Second()%tickSec 294 | if !first && wait == 0 { 295 | wait = tickSec 296 | } 297 | 298 | if wait < 1 || wait > tickSec { 299 | return now, willTime 300 | } 301 | 302 | next := now.Add(time.Duration(wait) * time.Second) 303 | willTime = timed && next.After(t.until) 304 | if t.verbose && !willTime { 305 | t.Log.Printf("[tasker] next tick on %s", next.Format(dateFormat)) 306 | } 307 | 308 | if willTime { 309 | next = now.Add(time.Duration(tickSec) - now.Sub(t.until)) 310 | } 311 | for !t.abort && !t.timeout && t.now().Before(next) { 312 | time.Sleep(100 * time.Millisecond) 313 | } 314 | 315 | t.timeout = timed && next.After(t.until) 316 | 317 | return next, willTime 318 | } 319 | 320 | func (t *Tasker) runTasks(tasks map[string]TaskFunc) { 321 | if t.verbose { 322 | if t.abort { 323 | t.Log.Println("[tasker] completing pending tasks") 324 | } else { 325 | t.Log.Printf("[tasker] running %d due tasks\n", len(tasks)) 326 | } 327 | } 328 | 329 | ctx := context.Background() 330 | if t.ctx != nil { 331 | ctx = t.ctx 332 | } 333 | 334 | for ref, task := range tasks { 335 | if !t.canRun(ref) { 336 | continue 337 | } 338 | 339 | t.wg.Add(1) 340 | rc := make(chan result) 341 | 342 | go t.doRun(ctx, ref, task, rc) 343 | go t.doOut(rc) 344 | } 345 | } 346 | 347 | func (t *Tasker) canRun(ref string) bool { 348 | lock, ok := t.mutex[ref] 349 | return !ok || atomic.CompareAndSwapUint32(lock, 0, 1) 350 | } 351 | 352 | func (t *Tasker) doRun(ctx context.Context, ref string, task TaskFunc, rc chan result) { 353 | defer t.wg.Done() 354 | if t.abort || t.timeout { 355 | return 356 | } 357 | 358 | if t.verbose { 359 | t.Log.Printf("[tasker] task %s running\n", ref) 360 | } 361 | 362 | code, err := task(ctx) 363 | if lock, ok := t.mutex[ref]; ok { 364 | atomic.StoreUint32(lock, 0) 365 | } 366 | 367 | rc <- result{err, ref, code} 368 | } 369 | 370 | func (t *Tasker) doOut(rc chan result) { 371 | res := <-rc 372 | if res.err != nil { 373 | t.Log.Printf("[tasker] task %s errored %v", res.ref, res.err) 374 | } 375 | 376 | if t.verbose { 377 | if res.code == 0 { 378 | t.Log.Printf("[tasker] task %s ran successfully", res.ref) 379 | } else { 380 | t.Log.Printf("[tasker] task %s returned error code: %d", res.ref, res.code) 381 | } 382 | } 383 | } 384 | 385 | func (t *Tasker) wait() { 386 | if !t.abort { 387 | t.Log.Println("[tasker] timed out, waiting tasks to complete") 388 | } else { 389 | t.Log.Println("[tasker] interrupted, waiting tasks to complete") 390 | } 391 | 392 | t.wg.Wait() 393 | 394 | // Allow a leeway period 395 | time.Sleep(100 * time.Microsecond) 396 | } 397 | -------------------------------------------------------------------------------- /pkg/tasker/tasker_other.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package tasker 5 | 6 | import ( 7 | "context" 8 | "log" 9 | "os/exec" 10 | "strings" 11 | "syscall" 12 | ) 13 | 14 | // Taskify creates TaskFunc out of plain command wrt given options. 15 | func (t *Tasker) Taskify(cmd string, opt Option) TaskFunc { 16 | sh := Shell(opt.Shell) 17 | 18 | return func(ctx context.Context) (int, error) { 19 | buf := strings.Builder{} 20 | exc := exec.Command(sh[0], sh[1], cmd) 21 | exc.Stderr = &buf 22 | exc.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 23 | 24 | if t.Log.Writer() != exc.Stderr { 25 | exc.Stdout = t.Log.Writer() 26 | } 27 | 28 | err := exc.Run() 29 | if err == nil { 30 | return 0, nil 31 | } 32 | 33 | for _, ln := range strings.Split(strings.TrimRight(buf.String(), "\r\n"), "\n") { 34 | log.Println(ln) 35 | } 36 | 37 | code := 1 38 | if exErr, ok := err.(*exec.ExitError); ok { 39 | code = exErr.ExitCode() 40 | } 41 | 42 | return code, err 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/tasker/tasker_test.go: -------------------------------------------------------------------------------- 1 | package tasker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "strings" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestNew(t *testing.T) { 14 | exit = func(code int) {} 15 | t.Run("New invalid Tz", func(t *testing.T) { 16 | New(Option{Tz: "Local/Xyz"}) 17 | }) 18 | t.Run("New invalid Out", func(t *testing.T) { 19 | New(Option{Out: "/a/b/c/d/e/f/out.log"}) 20 | }) 21 | t.Run("Invalid Until", func(t *testing.T) { 22 | var zero time.Time 23 | 24 | taskr := New(Option{}) 25 | taskr.Until(time.Now().Add(time.Minute)) 26 | 27 | taskr.Until(zero) 28 | taskr.Until(1) 29 | if !taskr.until.IsZero() { 30 | t.Error("tasker.until should be zero") 31 | } 32 | }) 33 | } 34 | 35 | func TestRun(t *testing.T) { 36 | t.Run("Run", func(t *testing.T) { 37 | tickSec = 1 38 | taskr := New(Option{Verbose: true, Out: "../../test/tasker.out"}) 39 | 40 | called := 0 41 | taskr.Task("* * * * * *", func(_ context.Context) (int, error) { 42 | taskr.Log.Println("task [* * * * * *][#1] sleeping 1s") 43 | time.Sleep(time.Second) 44 | called++ 45 | 46 | return 0, nil 47 | }) 48 | 49 | // dummy task that will never execute 50 | taskr.Task("* * * * * 2022", func(_ context.Context) (int, error) { 51 | return 0, nil 52 | }) 53 | 54 | time.Sleep(time.Second - time.Duration(time.Now().Nanosecond())) 55 | 56 | dur := 2500 * time.Millisecond 57 | now := time.Now() 58 | 59 | taskr.Until(dur).Run() 60 | 61 | if called != 2 { 62 | t.Errorf("task should run 2 times, ran %d times", called) 63 | } 64 | 65 | wait := tickSec - now.Second()%tickSec 66 | tickDur := time.Duration(wait) * time.Second 67 | start := now.Format(dateFormat) 68 | end := now.Add(dur).Format(dateFormat) 69 | next1 := now.Add(tickDur).Format(dateFormat) 70 | fin1 := now.Add(tickDur + 2*time.Second).Format(dateFormat) 71 | next2 := now.Add(tickDur + time.Duration(tickSec)*time.Second).Format(dateFormat) 72 | fin2 := now.Add(tickDur + time.Duration(tickSec)*time.Second).Format(dateFormat) 73 | 74 | buffers := []string{ 75 | start + " [tasker] final tick on or before " + end, 76 | start + " [tasker] next tick on " + next1, 77 | 78 | next1 + " [tasker] running 1 due tasks", 79 | next1 + " [tasker] next tick on " + next2, 80 | next1 + " [tasker] task [* * * * * *][#1] running", 81 | next1 + " task [* * * * * *][#1] sleeping 1s", 82 | 83 | next2 + " [tasker] running 1 due tasks", 84 | next2 + " [tasker] task [* * * * * *][#1] running", 85 | next2 + " task [* * * * * *][#1] sleeping 1s", 86 | 87 | fin1 + " [tasker] task [* * * * * *][#1] ran successfully", 88 | end + " [tasker] timed out, waiting tasks to complete", 89 | fin2 + " [tasker] task [* * * * * *][#1] ran successfully", 90 | } 91 | 92 | buf, _ := ioutil.ReadFile("../../test/tasker.out") 93 | buffer := string(buf) 94 | fmt.Println(buffer) 95 | 96 | for _, expect := range buffers { 97 | if !strings.Contains(buffer, expect) { 98 | t.Errorf("buffer should contain %s", expect) 99 | } 100 | } 101 | }) 102 | } 103 | 104 | func TestTaskify(t *testing.T) { 105 | t.Run("Taskify", func(t *testing.T) { 106 | ctx := context.TODO() 107 | taskr := New(Option{}) 108 | code, err := taskr.Taskify("echo -n 'taskify' > ../../test/taskify.out; echo 'test' >> ../../test/taskify.out", Option{})(ctx) 109 | 110 | if code != 0 { 111 | t.Errorf("expected code 0, got %d", code) 112 | } 113 | if err != nil { 114 | t.Errorf("expected no error, got %v", err) 115 | } 116 | 117 | t.Run("Taskify err", func(t *testing.T) { 118 | ctx := context.TODO() 119 | taskr := New(Option{}) 120 | code, err := taskr.Taskify("false", Option{})(ctx) 121 | if code != 1 { 122 | t.Errorf("expected code 127, got %d", code) 123 | } 124 | if err == nil { 125 | t.Error("expected error") 126 | } 127 | }) 128 | }) 129 | } 130 | 131 | func TestWithContext(t *testing.T) { 132 | // tickSec = 2 133 | t.Run("WithContext", func(t *testing.T) { 134 | os.Remove("../../test/tasker-ctx.out") 135 | ctx, cancel := context.WithCancel(context.Background()) 136 | taskr := New(Option{Verbose: true, Out: "../../test/tasker-ctx.out"}).WithContext(ctx) 137 | 138 | called := 0 139 | taskr.Task("* * * * * *", func(ctx context.Context) (int, error) { 140 | called++ 141 | ct := 0 142 | Over: 143 | for { 144 | time.Sleep(300 * time.Millisecond) 145 | select { 146 | case <-ctx.Done(): 147 | break Over 148 | default: 149 | ct++ 150 | } 151 | } 152 | return 0, nil 153 | }) 154 | 155 | startCh := make(chan bool) 156 | 157 | go func() { 158 | <-startCh 159 | time.Sleep(2100 * time.Millisecond) 160 | cancel() 161 | }() 162 | 163 | startCh <- true 164 | taskr.Until(2200 * time.Millisecond).Run() 165 | 166 | if called != 2 { 167 | t.Errorf("task should run 2 times, ran %d times", called) 168 | } 169 | 170 | buf, _ := ioutil.ReadFile("../../test/tasker-ctx.out") 171 | fmt.Println(string(buf)) 172 | }) 173 | } 174 | 175 | func TestConcurrency(t *testing.T) { 176 | t.Run("Run", func(t *testing.T) { 177 | taskr := New(Option{Verbose: true, Out: "../../test/tasker.out"}) 178 | 179 | single := 0 180 | taskr.Task("* * * * * *", func(ctx context.Context) (int, error) { 181 | time.Sleep(2500 * time.Millisecond) 182 | single++ 183 | return 0, nil 184 | }, false) 185 | 186 | concurrent := 0 187 | taskr.Task("* * * * * *", func(ctx context.Context) (int, error) { 188 | time.Sleep(1 * time.Second) 189 | concurrent++ 190 | return 0, nil 191 | }, true) 192 | 193 | taskr.Until(3 * time.Second).Run() 194 | 195 | if single != 1 { 196 | t.Errorf("single task should run 1x, not %dx", single) 197 | } 198 | if concurrent != 2 { 199 | t.Errorf("concurrent task should run 2x, not %dx", concurrent) 200 | } 201 | }) 202 | } 203 | 204 | func TestStopTasker(t *testing.T) { 205 | t.Run("call stop()", func(t *testing.T) { 206 | taskr := New(Option{Verbose: true, Out: "../../test/tasker.out"}) 207 | 208 | var incr int 209 | taskr.Task("* * * * * *", func(ctx context.Context) (int, error) { 210 | incr++ 211 | return 0, nil 212 | }, false) 213 | 214 | go func() { 215 | time.Sleep(2 * time.Second) 216 | taskr.Stop() 217 | }() 218 | taskr.Run() 219 | 220 | if incr != 1 { 221 | t.Errorf("the task should run 1x, not %dx", incr) 222 | } 223 | }) 224 | 225 | t.Run("cancel context", func(t *testing.T) { 226 | ctx, cancel := context.WithCancel(context.Background()) 227 | taskr := New(Option{Verbose: true, Out: "../../test/tasker.out"}).WithContext(ctx) 228 | 229 | var incr int 230 | taskr.Task("* * * * * *", func(ctx context.Context) (int, error) { 231 | incr++ 232 | return 0, nil 233 | }, false) 234 | 235 | go func() { 236 | time.Sleep(2 * time.Second) 237 | cancel() 238 | }() 239 | taskr.Run() 240 | 241 | if incr != 1 { 242 | t.Errorf("the task should run 1x, not %dx", incr) 243 | } 244 | }) 245 | } 246 | -------------------------------------------------------------------------------- /pkg/tasker/tasker_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package tasker 5 | 6 | import ( 7 | "context" 8 | "log" 9 | "os/exec" 10 | "strings" 11 | "syscall" 12 | ) 13 | 14 | // Taskify creates TaskFunc out of plain command wrt given options. 15 | 16 | func (t *Tasker) Taskify(cmd string, opt Option) TaskFunc { 17 | sh := Shell(opt.Shell) 18 | 19 | return func(ctx context.Context) (int, error) { 20 | buf := strings.Builder{} 21 | exc := exec.Command(sh[0], sh[1], cmd) 22 | exc.Stderr = &buf 23 | exc.SysProcAttr = &syscall.SysProcAttr{ 24 | CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, 25 | } 26 | 27 | if t.Log.Writer() != exc.Stderr { 28 | exc.Stdout = t.Log.Writer() 29 | } 30 | 31 | err := exc.Run() 32 | if err == nil { 33 | return 0, nil 34 | } 35 | 36 | for _, ln := range strings.Split(strings.TrimRight(buf.String(), "\r\n"), "\n") { 37 | log.Println(ln) 38 | } 39 | 40 | code := 1 41 | if exErr, ok := err.(*exec.ExitError); ok { 42 | code = exErr.ExitCode() 43 | } 44 | 45 | return code, err 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /prev.go: -------------------------------------------------------------------------------- 1 | package gronx 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // PrevTick gives previous run time before now 9 | func PrevTick(expr string, inclRefTime bool) (time.Time, error) { 10 | return PrevTickBefore(expr, time.Now(), inclRefTime) 11 | } 12 | 13 | // PrevTickBefore gives previous run time before given reference time 14 | func PrevTickBefore(expr string, start time.Time, inclRefTime bool) (time.Time, error) { 15 | gron, prev := New(), start.Truncate(time.Second) 16 | due, err := gron.IsDue(expr, start) 17 | if err != nil || (due && inclRefTime) { 18 | return prev, err 19 | } 20 | 21 | segments, _ := Segments(expr) 22 | if len(segments) > 6 && isUnreachableYear(segments[6], prev, true) { 23 | return prev, fmt.Errorf("unreachable year segment: %s", segments[6]) 24 | } 25 | 26 | prev, err = loop(gron, segments, prev, inclRefTime, true) 27 | // Ignore superfluous err 28 | if err != nil && gron.isDue(expr, prev) { 29 | err = nil 30 | } 31 | return prev, err 32 | } 33 | 34 | func bumpReverse(ref time.Time, pos int) time.Time { 35 | loc := ref.Location() 36 | 37 | switch pos { 38 | case 0: 39 | ref = ref.Add(-time.Second) 40 | case 1: 41 | minTime := ref.Add(-time.Minute) 42 | ref = time.Date(minTime.Year(), minTime.Month(), minTime.Day(), minTime.Hour(), minTime.Minute(), 59, 0, loc) 43 | case 2: 44 | hTime := ref.Add(-time.Hour) 45 | ref = time.Date(hTime.Year(), hTime.Month(), hTime.Day(), hTime.Hour(), 59, 59, 0, loc) 46 | case 3, 5: 47 | dTime := ref.AddDate(0, 0, -1) 48 | ref = time.Date(dTime.Year(), dTime.Month(), dTime.Day(), 23, 59, 59, 0, loc) 49 | case 4: 50 | ref = time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, loc) 51 | ref = ref.Add(-time.Second) 52 | case 6: 53 | yTime := ref.AddDate(-1, 0, 0) 54 | ref = time.Date(yTime.Year(), 12, 31, 23, 59, 59, 0, loc) 55 | } 56 | return ref 57 | } 58 | -------------------------------------------------------------------------------- /prev_test.go: -------------------------------------------------------------------------------- 1 | package gronx 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestPrevTick(t *testing.T) { 11 | exp := "* * * * * *" 12 | t.Run("prev tick "+exp, func(t *testing.T) { 13 | ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02") 14 | prev, _ := PrevTickBefore(exp, ref, true) 15 | if prev.Format(FullDateFormat) != "2020-02-02 02:02:02" { 16 | t.Errorf("[incl] expected %v, got %v", ref, prev) 17 | } 18 | 19 | expect := time.Now().Add(-time.Second).Format(FullDateFormat) 20 | prev, _ = PrevTick(exp, false) 21 | if expect != prev.Format(FullDateFormat) { 22 | t.Errorf("expected %v, got %v", expect, prev) 23 | } 24 | }) 25 | 26 | t.Run("prev tick excl "+exp, func(t *testing.T) { 27 | ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02") 28 | prev, _ := PrevTickBefore(exp, ref, false) 29 | if prev.Format(FullDateFormat) != "2020-02-02 02:02:01" { 30 | t.Errorf("[excl] expected %v, got %v", ref, prev) 31 | } 32 | }) 33 | } 34 | 35 | func TestPrevTickBefore(t *testing.T) { 36 | t.Run("prev tick before", func(t *testing.T) { 37 | t.Run("seconds precision", func(t *testing.T) { 38 | ref, _ := time.Parse(FullDateFormat, "2020-02-02 02:02:02") 39 | next, _ := NextTickAfter("*/5 * * * * *", ref, false) 40 | prev, _ := PrevTickBefore("*/5 * * * * *", next, false) 41 | if prev.Format(FullDateFormat) != "2020-02-02 02:02:00" { 42 | t.Errorf("next > prev should be %s, got %s", "2020-02-02 02:02:00", prev) 43 | } 44 | }) 45 | 46 | for i, test := range testcases() { 47 | t.Run(fmt.Sprintf("prev tick #%d: %s", i, test.Expr), func(t *testing.T) { 48 | ref, _ := time.Parse(FullDateFormat, test.Ref) 49 | next1, err := NextTickAfter(test.Expr, ref, false) 50 | if err != nil { 51 | return 52 | } 53 | 54 | prev1, err := PrevTickBefore(test.Expr, next1, true) 55 | if err != nil { 56 | if strings.HasPrefix(err.Error(), "unreachable year") { 57 | return 58 | } 59 | t.Errorf("%v", err) 60 | } 61 | 62 | if next1.Format(FullDateFormat) != prev1.Format(FullDateFormat) { 63 | t.Errorf("next->prev expect %s, got %s", next1, prev1) 64 | } 65 | 66 | next2, _ := NextTickAfter(test.Expr, next1, false) 67 | prev2, err := PrevTickBefore(test.Expr, next2, false) 68 | if err != nil { 69 | if strings.HasPrefix(err.Error(), "unreachable year") { 70 | return 71 | } 72 | t.Errorf("%s", err) 73 | } 74 | 75 | if next1.Format(FullDateFormat) != prev2.Format(FullDateFormat) { 76 | t.Errorf("next->next->prev expect %s, got %s", next1, prev2) 77 | } 78 | }) 79 | } 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /test/taskfile-complex.txt: -------------------------------------------------------------------------------- 1 | */1 * * * * echo "1 $(date +'%Y-%m-%d %T')" 2 | */2 * ? * ? * echo "2 $(date +'%Y-%m-%d %T')" 3 | */3 * ? * ? 0 2000-2024/4 echo "3 $(date +'%Y-%m-%d %T')" 4 | */4 * ? * ? 0 2023 echo "4 $(date +'%Y-%m-%d %T')" 5 | */5 * ? * ? * 2020-2030 echo "5 $(date +'%Y-%m-%d %T')" 6 | */6 * ? * ? 1,2 echo "6 $(date +'%Y-%m-%d %T')" 7 | */7 * ? * ? 1#4 2003,2005 echo "7 $(date +'%Y-%m-%d %T')" 8 | */8 * ? * ? 2-7/3 echo "8 $(date +'%Y-%m-%d %T')" 9 | */9 * ? * ? 1-6 */5 echo "9 $(date +'%Y-%m-%d %T')" 10 | */10 * ? * ? sun-mon * echo "10 $(date +'%Y-%m-%d %T')" 11 | */11 * ? * ? sun 2020 echo "11 $(date +'%Y-%m-%d %T')" 12 | */12 * ? * ? mon echo "12 $(date +'%Y-%m-%d %T')" 13 | */13 * ? * ? 1#4 echo "13 $(date +'%Y-%m-%d %T')" 14 | -------------------------------------------------------------------------------- /test/taskfile.txt: -------------------------------------------------------------------------------- 1 | # this taskfile contains 5 tasks, lines starting with # are comments 2 | */1 0/1 * * * echo '[task 1] */1 0/1 * * *' > test/task1.out 3 | * * * * * 2021 echo '[task 2] * * * * * 2021' > test/task2.out 4 | 5 | # below three are equivalent 6 | @always echo '[task 3] @always' > test/task3.out 7 | @always echo '[task 4] @always' > test/task4.out 8 | * * * * * echo '[task 5] * * * * *' > test/task5.out 9 | * * * * * echo '[task 6] it should go to outfile' && invalid-cmd 10 | 11 | # failure tasks 12 | @always xgronx 13 | @always false 14 | 15 | # below are invalid 16 | @invalid 17 | * * * * * 18 | -------------------------------------------------------------------------------- /validator.go: -------------------------------------------------------------------------------- 1 | package gronx 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | func inStep(val int, s string, bounds []int) (bool, error) { 12 | parts := strings.Split(s, "/") 13 | step, err := strconv.Atoi(parts[1]) 14 | if err != nil { 15 | return false, err 16 | } 17 | if step <= 0 { 18 | return false, errors.New("step can't be 0") 19 | } 20 | 21 | if strings.Index(s, "*/") == 0 { 22 | return (val-bounds[0])%step == 0, nil 23 | } 24 | if strings.Index(s, "0/") == 0 { 25 | return val%step == 0, nil 26 | } 27 | 28 | sub, end := strings.Split(parts[0], "-"), val 29 | start, err := strconv.Atoi(sub[0]) 30 | if err != nil { 31 | return false, err 32 | } 33 | 34 | if len(sub) > 1 { 35 | end, err = strconv.Atoi(sub[1]) 36 | if err != nil { 37 | return false, err 38 | } 39 | } 40 | 41 | if (len(sub) > 1 && end < start) || start < bounds[0] || end > bounds[1] { 42 | return false, fmt.Errorf("step '%s' out of bounds(%d, %d)", parts[0], bounds[0], bounds[1]) 43 | } 44 | 45 | return inStepRange(val, start, end, step), nil 46 | } 47 | 48 | func inRange(val int, s string, bounds []int) (bool, error) { 49 | parts := strings.Split(s, "-") 50 | start, err := strconv.Atoi(parts[0]) 51 | if err != nil { 52 | return false, err 53 | } 54 | 55 | end, err := strconv.Atoi(parts[1]) 56 | if err != nil { 57 | return false, err 58 | } 59 | 60 | if end < start || start < bounds[0] || end > bounds[1] { 61 | return false, fmt.Errorf("range '%s' out of bounds(%d, %d)", s, bounds[0], bounds[1]) 62 | } 63 | 64 | return start <= val && val <= end, nil 65 | } 66 | 67 | func inStepRange(val, start, end, step int) bool { 68 | for i := start; i <= end && i <= val; i += step { 69 | if i == val { 70 | return true 71 | } 72 | } 73 | return false 74 | } 75 | 76 | func isValidMonthDay(val string, last int, ref time.Time) (valid bool, err error) { 77 | day, loc := ref.Day(), ref.Location() 78 | if val == "L" { 79 | return day == last, nil 80 | } 81 | 82 | pos := strings.Index(val, "W") 83 | if pos < 1 { 84 | return false, errors.New("invalid offset value: " + val) 85 | } 86 | 87 | nval, err := strconv.Atoi(val[0:pos]) 88 | if err != nil { 89 | return false, err 90 | } 91 | 92 | for _, i := range []int{0, -1, 1, -2, 2} { 93 | incr := i + nval 94 | if incr > 0 && incr <= last { 95 | iref := time.Date(ref.Year(), ref.Month(), incr, ref.Hour(), ref.Minute(), ref.Second(), 0, loc) 96 | week := int(iref.Weekday()) 97 | 98 | if week > 0 && week < 6 && iref.Month() == ref.Month() { 99 | valid = day == iref.Day() 100 | break 101 | } 102 | } 103 | } 104 | 105 | return valid, nil 106 | } 107 | 108 | func isValidWeekDay(val string, last int, ref time.Time) (bool, error) { 109 | loc := ref.Location() 110 | 111 | if pos := strings.Index(val, "L"); pos > 0 { 112 | nval, err := strconv.Atoi(val[0:pos]) 113 | if err != nil { 114 | return false, err 115 | } 116 | 117 | for i := 0; i < 7; i++ { 118 | day := last - i 119 | dref := time.Date(ref.Year(), ref.Month(), day, ref.Hour(), ref.Minute(), ref.Second(), 0, loc) 120 | if int(dref.Weekday()) == nval%7 { 121 | return ref.Day() == day, nil 122 | } 123 | } 124 | } 125 | 126 | pos := strings.Index(val, "#") 127 | parts := strings.Split(strings.ReplaceAll(val, "7#", "0#"), "#") 128 | if pos < 1 || len(parts) < 2 { 129 | return false, errors.New("invalid offset value: " + val) 130 | } 131 | 132 | day, err := strconv.Atoi(parts[0]) 133 | if err != nil { 134 | return false, err 135 | } 136 | 137 | nth, err := strconv.Atoi(parts[1]) 138 | if err != nil { 139 | return false, err 140 | } 141 | 142 | if day < 0 || day > 7 || nth < 1 || nth > 5 || int(ref.Weekday()) != day { 143 | return false, nil 144 | } 145 | 146 | return (ref.Day()-1)/7 == nth-1, nil 147 | } 148 | --------------------------------------------------------------------------------