├── .gitignore ├── .gitattributes ├── go.mod ├── remove.go ├── task.go ├── go.sum ├── LICENSE ├── .github └── workflows │ └── test.yml ├── add.go ├── type.go ├── dependManager.go ├── depend.go ├── instance.go ├── schedule.go ├── README.zh.md ├── README.md └── cron_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | memo.md 3 | *.log -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # 指定文件的語言 2 | *.go linguist-language=Go 3 | 4 | # 將以下的所有文件與檔案視為第三方代碼,不計入語言統計 5 | *.mod linguist-vendored 6 | *.sum linguist-vendored 7 | 8 | # 視文件為文檔類型,忽略語言統計 9 | *.md linguist-documentation 10 | *.txt linguist-documentation 11 | LICENSE linguist-documentation -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pardnchiu/go-scheduler 2 | 3 | go 1.23 4 | 5 | require github.com/stretchr/testify v1.10.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /remove.go: -------------------------------------------------------------------------------- 1 | package goCron 2 | 3 | import ( 4 | "container/heap" 5 | ) 6 | 7 | func (c *cron) RemoveAll() { 8 | c.mutex.Lock() 9 | defer c.mutex.Unlock() 10 | 11 | if c.running { 12 | c.removeAll <- struct{}{} 13 | return 14 | } 15 | 16 | for i := range c.heap { 17 | c.heap[i].enable = false 18 | } 19 | heap.Init(&c.heap) 20 | } 21 | 22 | func (c *cron) Remove(id int64) { 23 | c.mutex.Lock() 24 | defer c.mutex.Unlock() 25 | 26 | if c.running { 27 | c.remove <- id 28 | return 29 | } 30 | 31 | for i, entry := range c.heap { 32 | if entry.ID == id { 33 | entry.enable = false 34 | heap.Remove(&c.heap, i) 35 | break 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /task.go: -------------------------------------------------------------------------------- 1 | package goCron 2 | 3 | func (c *cron) List() []task { 4 | c.mutex.Lock() 5 | defer c.mutex.Unlock() 6 | 7 | tasks := make([]task, 0, len(c.heap)) 8 | for _, t := range c.heap { 9 | if t.enable { 10 | tasks = append(tasks, *t) 11 | } 12 | } 13 | return tasks 14 | } 15 | 16 | func (h taskHeap) Len() int { 17 | return len(h) 18 | } 19 | 20 | func (h taskHeap) Less(i, j int) bool { 21 | return h[i].next.Before(h[j].next) 22 | } 23 | 24 | func (h taskHeap) Swap(i, j int) { 25 | h[i], h[j] = h[j], h[i] 26 | } 27 | 28 | func (h *taskHeap) Push(x interface{}) { 29 | *h = append(*h, x.(*task)) 30 | } 31 | 32 | func (h *taskHeap) Pop() interface{} { 33 | old := *h 34 | n := len(old) 35 | item := old[n-1] 36 | *h = old[0 : n-1] 37 | return item 38 | } 39 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 邱敬幃 Pardn Chiu 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 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | go-version: [ '1.23' ] 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: ${{ matrix.go-version }} 26 | 27 | - name: Cache Go modules 28 | uses: actions/cache@v4 29 | with: 30 | path: | 31 | ~/.cache/go-build 32 | ~/go/pkg/mod 33 | key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }} 34 | restore-keys: | 35 | ${{ runner.os }}-go-${{ matrix.go-version }}- 36 | 37 | - name: Download dependencies 38 | run: go mod download 39 | 40 | - name: Verify dependencies 41 | run: go mod verify 42 | 43 | - name: Run tests 44 | run: go test -v -race -coverprofile=coverage.out ./... 45 | 46 | - name: Run benchmarks 47 | run: go test -bench=. -benchmem ./... 48 | 49 | - name: Check coverage 50 | run: go tool cover -html=coverage.out -o coverage.html 51 | 52 | - name: Upload coverage reports 53 | uses: codecov/codecov-action@v4 54 | if: matrix.go-version == '1.23' 55 | with: 56 | file: ./coverage.out 57 | flags: unittests 58 | name: codecov-umbrella 59 | fail_ci_if_error: false 60 | -------------------------------------------------------------------------------- /add.go: -------------------------------------------------------------------------------- 1 | package goCron 2 | 3 | import ( 4 | "container/heap" 5 | "fmt" 6 | "sync/atomic" 7 | "time" 8 | ) 9 | 10 | func (c *cron) Add(spec string, action interface{}, arg ...interface{}) (int64, error) { 11 | schedule, err := c.parser.parse(spec) 12 | if err != nil { 13 | return 0, fmt.Errorf("failed to parse: %w", err) 14 | } 15 | 16 | c.mutex.Lock() 17 | defer c.mutex.Unlock() 18 | 19 | entry := &task{ 20 | ID: atomic.AddInt64(&c.next, 1), 21 | schedule: schedule, 22 | enable: true, 23 | state: TaskPending, 24 | } 25 | 26 | withError := false 27 | 28 | switch v := action.(type) { 29 | // * 無返回錯誤值 30 | case func(): 31 | entry.action = func() error { 32 | v() 33 | return nil 34 | } 35 | // * 設為已完成 36 | entry.state = TaskCompleted 37 | // * 有返回錯誤值 38 | case func() error: 39 | // * 標記有回傳值 40 | withError = true 41 | entry.action = v 42 | default: 43 | return 0, fmt.Errorf("action need to be func() or func()") 44 | } 45 | 46 | var after []Wait 47 | for _, e := range arg { 48 | switch v := e.(type) { 49 | case string: 50 | entry.description = v 51 | case time.Duration: 52 | entry.delay = v 53 | case func(): 54 | entry.onDelay = v 55 | // * 依賴任務 56 | case []Wait: 57 | after = append(after, v...) 58 | entry.state = TaskPending 59 | // ! Deprecated in v2.*.* 60 | case []int64: 61 | for _, id := range v { 62 | after = append(after, Wait{ID: id}) 63 | } 64 | entry.state = TaskPending 65 | } 66 | } 67 | 68 | if !withError && after != nil { 69 | return 0, fmt.Errorf("need return value to get dependence support") 70 | } 71 | 72 | if after != nil { 73 | entry.after = make([]Wait, len(after)) 74 | copy(entry.after, after) 75 | } 76 | 77 | if c.running { 78 | c.add <- entry 79 | } else { 80 | c.heap = append(c.heap, entry) 81 | heap.Init(&c.heap) 82 | c.depend.manager.add(entry) 83 | } 84 | 85 | return entry.ID, nil 86 | } 87 | -------------------------------------------------------------------------------- /type.go: -------------------------------------------------------------------------------- 1 | package goCron 2 | 3 | import ( 4 | "log/slog" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | var ( 10 | maxWorker = 2 11 | // logger *slog.Logger 12 | ) 13 | 14 | const ( 15 | TaskPending int = iota 16 | TaskRunning 17 | TaskCompleted 18 | TaskFailed 19 | ) 20 | 21 | type Config struct { 22 | Location *time.Location 23 | } 24 | 25 | type cron struct { 26 | mutex sync.Mutex 27 | wait sync.WaitGroup 28 | heap taskHeap 29 | parser parser 30 | stop chan struct{} 31 | add chan *task 32 | remove chan int64 33 | removeAll chan struct{} 34 | location *time.Location 35 | depend *depend 36 | next int64 37 | running bool 38 | logger *slog.Logger 39 | } 40 | 41 | type depend struct { 42 | mutex sync.RWMutex 43 | wait sync.WaitGroup 44 | manager *dependManager 45 | running bool 46 | queue chan Wait 47 | stopChan chan struct{} 48 | logger *slog.Logger 49 | } 50 | 51 | type Wait struct { 52 | ID int64 53 | Delay time.Duration 54 | State WaitState 55 | } 56 | 57 | type WaitState int 58 | 59 | const ( 60 | Stop WaitState = iota 61 | Skip 62 | ) 63 | 64 | type dependManager struct { 65 | mutex sync.RWMutex 66 | list map[int64]*task 67 | waiting map[int64][]*task 68 | } 69 | 70 | type task struct { 71 | mutex sync.RWMutex 72 | ID int64 73 | description string 74 | schedule schedule 75 | action func() error 76 | next time.Time 77 | prev time.Time 78 | enable bool 79 | delay time.Duration 80 | wait time.Duration 81 | waitState WaitState 82 | onDelay func() 83 | after []Wait 84 | state int 85 | result *taskResult 86 | startChan chan struct{} 87 | doneChan chan taskResult 88 | } 89 | 90 | type taskResult struct { 91 | ID int64 92 | status int 93 | start time.Time 94 | end time.Time 95 | duration time.Duration 96 | error error 97 | } 98 | 99 | type taskState struct { 100 | done bool 101 | waiting []Wait 102 | failed *int64 103 | error error 104 | } 105 | 106 | type schedule interface { 107 | next(time.Time) time.Time 108 | } 109 | 110 | type scheduleResult struct { 111 | minute, 112 | hour, 113 | dom, 114 | month, 115 | dow scheduleField 116 | } 117 | 118 | type scheduleField struct { 119 | Value int 120 | Values []int 121 | All bool 122 | Step int 123 | } 124 | 125 | type delayScheduleResult struct { 126 | delay time.Duration 127 | } 128 | 129 | type taskHeap []*task 130 | type parser struct{} 131 | -------------------------------------------------------------------------------- /dependManager.go: -------------------------------------------------------------------------------- 1 | package goCron 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | func newDependManager() *dependManager { 10 | return &dependManager{ 11 | list: make(map[int64]*task), 12 | waiting: make(map[int64][]*task), 13 | } 14 | } 15 | 16 | func (m *dependManager) add(t *task) { 17 | m.mutex.Lock() 18 | defer m.mutex.Unlock() 19 | 20 | m.list[t.ID] = t 21 | 22 | t.mutex.RLock() 23 | hasAfter := len(t.after) > 0 24 | t.mutex.RUnlock() 25 | 26 | // * 存在依賴任務 27 | if hasAfter { 28 | t.startChan = make(chan struct{}, 1) 29 | t.doneChan = make(chan taskResult, 1) 30 | } 31 | 32 | if t.state == 0 { 33 | t.state = TaskPending 34 | } 35 | } 36 | 37 | func (m *dependManager) check(id int64) taskState { 38 | m.mutex.RLock() 39 | defer m.mutex.RUnlock() 40 | 41 | task, isExist := m.list[id] 42 | // * 任務不存在 43 | if !isExist { 44 | return taskState{ 45 | done: false, 46 | error: fmt.Errorf("task not found: %d", id), 47 | } 48 | } 49 | 50 | task.mutex.RLock() 51 | defer task.mutex.RUnlock() 52 | 53 | var waiting []Wait 54 | 55 | for _, e := range task.after { 56 | afterTask, isExist := m.list[e.ID] 57 | // * 依賴任務不存在 58 | if !isExist { 59 | return taskState{ 60 | done: false, 61 | failed: &e.ID, 62 | error: fmt.Errorf("dependence Task not found: %d", id), 63 | } 64 | } 65 | 66 | afterTask.mutex.RLock() 67 | status := afterTask.state 68 | afterTask.mutex.RUnlock() 69 | 70 | // * 依賴任務執行錯誤處理 71 | if status == TaskFailed { 72 | if e.State == Stop { 73 | return taskState{ 74 | done: false, 75 | failed: &e.ID, 76 | error: fmt.Errorf("dependence Task is failed: %d", id), 77 | } 78 | } 79 | continue 80 | } 81 | 82 | // * 依賴任務未完成 83 | if status != TaskCompleted { 84 | waiting = append(waiting, e) 85 | } 86 | } 87 | 88 | // * 尚有依賴任務未完成 89 | if len(waiting) > 0 { 90 | return taskState{ 91 | done: false, 92 | waiting: waiting, 93 | error: fmt.Errorf("waiting for dependencies: %d", waiting), 94 | } 95 | } 96 | 97 | return taskState{ 98 | done: true, 99 | } 100 | } 101 | 102 | // TODO: 後續改寫觸發的方式 103 | func (m *dependManager) wait(id int64, timeout time.Duration) error { 104 | // * context 超時控制 105 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 106 | defer cancel() 107 | 108 | for { 109 | result := m.check(id) 110 | // * 依賴任務接完成 111 | if result.done { 112 | return nil 113 | } 114 | 115 | // * 依賴任務失敗 116 | if result.failed != nil { 117 | return fmt.Errorf("dependence Task failed: %d, %s", *result.failed, result.error.Error()) 118 | } 119 | 120 | select { 121 | case <-ctx.Done(): 122 | return fmt.Errorf("timeout waiting for dependencies: %s", result.error.Error()) 123 | case <-time.After(1 * time.Millisecond): 124 | } 125 | } 126 | } 127 | 128 | func (m *dependManager) update(result taskResult) { 129 | m.mutex.Lock() 130 | defer m.mutex.Unlock() 131 | 132 | if task, isExist := m.list[result.ID]; isExist { 133 | task.mutex.Lock() 134 | task.state = result.status 135 | task.result = &result 136 | task.mutex.Unlock() 137 | 138 | // * 完成通知 139 | select { 140 | case task.doneChan <- result: 141 | default: 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /depend.go: -------------------------------------------------------------------------------- 1 | package goCron 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "runtime" 7 | "time" 8 | ) 9 | 10 | func newDepend() *depend { 11 | cpu := runtime.NumCPU() 12 | if cpu > 2 { 13 | maxWorker = cpu 14 | } 15 | 16 | return &depend{ 17 | manager: newDependManager(), 18 | stopChan: make(chan struct{}), 19 | queue: make(chan Wait, 1024), 20 | } 21 | } 22 | 23 | func (d *depend) start() { 24 | d.mutex.Lock() 25 | defer d.mutex.Unlock() 26 | 27 | if d.running { 28 | return 29 | } 30 | d.running = true 31 | 32 | for i := 0; i < maxWorker; i++ { 33 | d.wait.Add(1) 34 | go d.worker() 35 | } 36 | } 37 | 38 | func (d *depend) stop() { 39 | d.mutex.Lock() 40 | defer d.mutex.Unlock() 41 | 42 | if !d.running { 43 | return 44 | } 45 | d.running = false 46 | 47 | close(d.stopChan) 48 | d.wait.Wait() 49 | } 50 | 51 | func (d *depend) worker() { 52 | defer d.wait.Done() 53 | 54 | for { 55 | select { 56 | case taskID := <-d.queue: 57 | d.runAfter(taskID) 58 | case <-d.stopChan: 59 | return 60 | } 61 | } 62 | } 63 | 64 | func (d *depend) addWait(id int64, delay time.Duration, state WaitState) { 65 | var timeout = 1 * time.Minute 66 | if delay > 0 { 67 | timeout = delay 68 | } 69 | d.queue <- Wait{ 70 | ID: id, 71 | Delay: timeout, 72 | State: state, 73 | } 74 | } 75 | 76 | // * Worker 執行的排序(v0.4.0 對 Worker 數進行了限制) 77 | func (d *depend) runAfter(queue Wait) { 78 | task, isExist := d.manager.list[queue.ID] 79 | if !isExist { 80 | d.logger.Error( 81 | "Task not found", 82 | "ID", int(queue.ID), 83 | ) 84 | return 85 | } 86 | 87 | task.mutex.RLock() 88 | status := task.state 89 | task.mutex.RUnlock() 90 | 91 | if status == TaskRunning || status == TaskCompleted { 92 | return 93 | } 94 | 95 | if err := d.manager.wait(queue.ID, queue.Delay); err != nil { 96 | result := taskResult{ 97 | ID: queue.ID, 98 | status: TaskFailed, 99 | start: time.Now(), 100 | end: time.Now(), 101 | error: err, 102 | } 103 | d.manager.update(result) 104 | d.logger.Error( 105 | "Dependence Task failed", 106 | "ID", int(queue.ID), 107 | "error", err, 108 | ) 109 | return 110 | } 111 | 112 | d.run(task) 113 | } 114 | 115 | func (d *depend) run(task *task) { 116 | start := time.Now() 117 | 118 | task.mutex.Lock() 119 | task.state = TaskRunning 120 | task.mutex.Unlock() 121 | 122 | d.logger.Info( 123 | "Task started", 124 | "ID", int(task.ID), 125 | "description", task.description, 126 | ) 127 | 128 | var taskError error 129 | 130 | func() { 131 | defer func() { 132 | if r := recover(); r != nil { 133 | taskError = fmt.Errorf("task panic: %v", r) 134 | d.logger.Error( 135 | "Task panic", 136 | "ID", int(task.ID), 137 | "panic", r, 138 | ) 139 | } 140 | }() 141 | 142 | if task.delay > 0 { 143 | ctx, cancel := context.WithTimeout(context.Background(), task.delay) 144 | defer cancel() 145 | 146 | done := make(chan error, 1) 147 | go func() { 148 | done <- task.action() 149 | }() 150 | 151 | select { 152 | case err := <-done: 153 | taskError = err 154 | case <-ctx.Done(): 155 | taskError = fmt.Errorf("task timeout %d", task.delay) 156 | if task.onDelay != nil { 157 | task.onDelay() 158 | } 159 | } 160 | } else { 161 | taskError = task.action() 162 | } 163 | }() 164 | 165 | end := time.Now() 166 | duration := end.Sub(start) 167 | 168 | status := TaskCompleted 169 | if taskError != nil { 170 | status = TaskFailed 171 | } 172 | 173 | result := taskResult{ 174 | ID: task.ID, 175 | status: status, 176 | start: start, 177 | end: end, 178 | duration: duration, 179 | error: taskError, 180 | } 181 | 182 | d.manager.update(result) 183 | 184 | if taskError != nil { 185 | d.logger.Error( 186 | "Task failed", 187 | "ID", int(task.ID), 188 | "duration", duration, 189 | "error", taskError, 190 | ) 191 | } else { 192 | d.logger.Info( 193 | "Task completed", 194 | "ID", int(task.ID), 195 | "duration", duration, 196 | ) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /instance.go: -------------------------------------------------------------------------------- 1 | package goCron 2 | 3 | import ( 4 | "container/heap" 5 | "context" 6 | "fmt" 7 | "log/slog" 8 | "log/syslog" 9 | "os" 10 | "time" 11 | ) 12 | 13 | func New(c Config) (*cron, error) { 14 | location := time.Local 15 | if c.Location != nil { 16 | location = c.Location 17 | } 18 | 19 | var logger *slog.Logger 20 | writer, err := syslog.New(syslog.LOG_INFO|syslog.LOG_LOCAL0, "goCron") 21 | if err != nil { 22 | logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ 23 | Level: slog.LevelInfo, 24 | })) 25 | } else { 26 | logger = slog.New(slog.NewJSONHandler(writer, &slog.HandlerOptions{ 27 | Level: slog.LevelInfo, 28 | })) 29 | } 30 | 31 | depend := newDepend(); 32 | depend.logger = logger 33 | 34 | cron := &cron{ 35 | heap: make(taskHeap, 0), 36 | parser: parser{}, 37 | stop: make(chan struct{}), 38 | add: make(chan *task), 39 | remove: make(chan int64), 40 | removeAll: make(chan struct{}), 41 | location: location, 42 | running: false, 43 | depend: depend, 44 | logger: logger, 45 | } 46 | 47 | return cron, nil 48 | } 49 | 50 | func (c *cron) Start() { 51 | c.mutex.Lock() 52 | defer c.mutex.Unlock() 53 | 54 | if !c.running { 55 | c.running = true 56 | c.depend.start() 57 | 58 | go func() { 59 | now := time.Now().In(c.location) 60 | 61 | for _, entry := range c.heap { 62 | entry.next = entry.schedule.next(now) 63 | } 64 | heap.Init(&c.heap) 65 | 66 | for { 67 | var timer *time.Timer 68 | var timerC <-chan time.Time 69 | 70 | if len(c.heap) == 0 || c.heap[0].next.IsZero() { 71 | timerC = nil 72 | } else { 73 | timer = time.NewTimer(c.heap[0].next.Sub(now)) 74 | timerC = timer.C 75 | } 76 | 77 | for { 78 | select { 79 | case now = <-timerC: 80 | // * 時間觸發 81 | now = now.In(c.location) 82 | 83 | for len(c.heap) > 0 && (c.heap[0].next.Before(now) || c.heap[0].next.Equal(now)) { 84 | e := heap.Pop(&c.heap).(*task) 85 | 86 | if !e.enable { 87 | continue 88 | } 89 | 90 | c.run(e) 91 | 92 | e.prev = e.next 93 | e.next = e.schedule.next(now) 94 | if !e.next.IsZero() { 95 | heap.Push(&c.heap, e) 96 | } 97 | } 98 | 99 | case newEntry := <-c.add: 100 | // * 新增任務觸發 101 | if timer != nil { 102 | timer.Stop() 103 | } 104 | now = time.Now().In(c.location) 105 | newEntry.next = newEntry.schedule.next(now) 106 | heap.Push(&c.heap, newEntry) 107 | c.depend.manager.add(newEntry) 108 | 109 | case id := <-c.remove: 110 | // * 移除任務觸發 111 | if timer != nil { 112 | timer.Stop() 113 | } 114 | now = time.Now().In(c.location) 115 | for i, entry := range c.heap { 116 | if entry.ID == id { 117 | entry.enable = false 118 | heap.Remove(&c.heap, i) 119 | break 120 | } 121 | } 122 | 123 | case <-c.removeAll: 124 | // * 移除任務觸發 125 | if timer != nil { 126 | timer.Stop() 127 | } 128 | now = time.Now().In(c.location) 129 | // 完全清空 heap 130 | for len(c.heap) > 0 { 131 | heap.Pop(&c.heap) 132 | } 133 | 134 | case <-c.stop: 135 | // * 移除任務觸發 136 | if timer != nil { 137 | timer.Stop() 138 | } 139 | return 140 | } 141 | break 142 | } 143 | } 144 | }() 145 | } 146 | } 147 | 148 | func (c *cron) Stop() context.Context { 149 | c.mutex.Lock() 150 | defer c.mutex.Unlock() 151 | 152 | if c.running { 153 | c.stop <- struct{}{} 154 | c.running = false 155 | c.depend.stop() 156 | } 157 | 158 | ctx, cancel := context.WithCancel(context.Background()) 159 | go func() { 160 | c.wait.Wait() 161 | cancel() 162 | }() 163 | 164 | return ctx 165 | } 166 | 167 | func (c *cron) run(e *task) { 168 | e.mutex.RLock() 169 | hasDeps := len(e.after) > 0 170 | e.mutex.RUnlock() 171 | 172 | if hasDeps { 173 | c.depend.addWait(e.ID, e.wait, e.waitState) 174 | } else { 175 | c.runAfter(e) 176 | } 177 | } 178 | 179 | func (c *cron) runAfter(e *task) { 180 | c.wait.Add(1) 181 | go func(entry *task) { 182 | defer func() { 183 | if r := recover(); r != nil { 184 | // * 更新狀態至錯誤 185 | entry.mutex.Lock() 186 | entry.state = TaskFailed 187 | entry.mutex.Unlock() 188 | 189 | c.logger.Info( 190 | "Recovered from panic", 191 | "ID", int(entry.ID), 192 | "error", r, 193 | ) 194 | } 195 | }() 196 | defer c.wait.Done() 197 | 198 | // * 更新狀態至執行中 199 | entry.mutex.Lock() 200 | entry.state = TaskRunning 201 | entry.mutex.Unlock() 202 | 203 | var taskError error 204 | if entry.delay > 0 { 205 | ctx, cancel := context.WithTimeout(context.Background(), entry.delay) 206 | defer cancel() 207 | 208 | done := make(chan struct{}) 209 | go func() { 210 | defer cancel() 211 | 212 | if err := entry.action(); err != nil { 213 | taskError = err 214 | c.logger.Error( 215 | "Task failed", 216 | "error", err, 217 | ) 218 | } 219 | close(done) 220 | }() 221 | 222 | select { 223 | case <-done: 224 | case <-ctx.Done(): 225 | // * 任務超時 226 | taskError = fmt.Errorf("task timeout: %d", entry.delay) 227 | if entry.onDelay != nil { 228 | entry.onDelay() 229 | } 230 | c.logger.Warn( 231 | "Task timeout", 232 | "ID", int(entry.ID), 233 | "delay", entry.delay, 234 | ) 235 | } 236 | } else { 237 | if err := entry.action(); err != nil { 238 | taskError = err 239 | c.logger.Error( 240 | "Task failed", 241 | "error", err, 242 | ) 243 | } 244 | } 245 | 246 | entry.mutex.Lock() 247 | if taskError != nil { 248 | // * 更新狀態至錯誤 249 | entry.state = TaskFailed 250 | } else { 251 | // * 更新狀態至完成 252 | entry.state = TaskCompleted 253 | } 254 | entry.mutex.Unlock() 255 | }(e) 256 | } 257 | -------------------------------------------------------------------------------- /schedule.go: -------------------------------------------------------------------------------- 1 | package goCron 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | func (parser) parse(spec string) (schedule, error) { 11 | if spec[0] == '@' { 12 | return parseDescriptor(spec) 13 | } 14 | return parseCron(spec) 15 | } 16 | 17 | func (r delayScheduleResult) next(t time.Time) time.Time { 18 | return t.Add(r.delay) 19 | } 20 | 21 | func (s *scheduleResult) next(t time.Time) time.Time { 22 | t = t.Add(time.Minute - time.Duration(t.Second())*time.Second) 23 | 24 | for { 25 | if s.matchTime(t) { 26 | return t 27 | } 28 | t = t.Add(time.Minute) 29 | } 30 | } 31 | 32 | func (s *scheduleResult) matchTime(t time.Time) bool { 33 | return s.matchField(s.minute, t.Minute()) && 34 | s.matchField(s.hour, t.Hour()) && 35 | s.matchField(s.dom, t.Day()) && 36 | s.matchField(s.month, int(t.Month())) && 37 | s.matchField(s.dow, int(t.Weekday())) 38 | } 39 | 40 | func (s *scheduleResult) matchField(field scheduleField, value int) bool { 41 | if field.All { 42 | return true 43 | } 44 | 45 | if field.Step > 0 { 46 | return value%field.Step == 0 47 | } 48 | 49 | if len(field.Values) > 0 { 50 | for _, v := range field.Values { 51 | if v == value { 52 | return true 53 | } 54 | } 55 | return false 56 | } 57 | 58 | return field.Value == value 59 | } 60 | 61 | func parseDescriptor(spec string) (schedule, error) { 62 | switch spec { 63 | case "@yearly", "@annually": 64 | return &scheduleResult{ 65 | scheduleField{Value: 0}, 66 | scheduleField{Value: 0}, 67 | scheduleField{Value: 1}, 68 | scheduleField{Value: 1}, 69 | scheduleField{All: true}, 70 | }, nil 71 | case "@monthly": 72 | return &scheduleResult{ 73 | scheduleField{Value: 0}, 74 | scheduleField{Value: 0}, 75 | scheduleField{Value: 1}, 76 | scheduleField{All: true}, 77 | scheduleField{All: true}, 78 | }, nil 79 | case "@weekly": 80 | return &scheduleResult{ 81 | scheduleField{Value: 0}, 82 | scheduleField{Value: 0}, 83 | scheduleField{All: true}, 84 | scheduleField{All: true}, 85 | scheduleField{Value: 0}, 86 | }, nil 87 | case "@daily", "@midnight": 88 | return &scheduleResult{ 89 | scheduleField{Value: 0}, 90 | scheduleField{Value: 0}, 91 | scheduleField{All: true}, 92 | scheduleField{All: true}, 93 | scheduleField{All: true}, 94 | }, nil 95 | case "@hourly": 96 | return &scheduleResult{ 97 | scheduleField{Value: 0}, 98 | scheduleField{All: true}, 99 | scheduleField{All: true}, 100 | scheduleField{All: true}, 101 | scheduleField{All: true}, 102 | }, nil 103 | } 104 | 105 | if strings.HasPrefix(spec, "@every ") { 106 | duration, err := time.ParseDuration(spec[7:]) 107 | if err != nil { 108 | return nil, fmt.Errorf("failed to parse @every: %v", err) 109 | } 110 | if duration < 30*time.Second { 111 | return nil, fmt.Errorf("@every minimum interval is 30s, got %v", duration) 112 | } 113 | return delayScheduleResult{duration}, nil 114 | } 115 | 116 | return nil, fmt.Errorf("failed to parse: %s", spec) 117 | } 118 | 119 | func parseCron(spec string) (schedule, error) { 120 | fields := strings.Fields(spec) 121 | if len(fields) != 5 { 122 | return nil, fmt.Errorf("requires 5 values, got %d", len(fields)) 123 | } 124 | 125 | schedule := &scheduleResult{} 126 | var err error 127 | 128 | if schedule.minute, err = parseField(fields[0], 0, 59); err != nil { 129 | return nil, err 130 | } 131 | if schedule.hour, err = parseField(fields[1], 0, 23); err != nil { 132 | return nil, err 133 | } 134 | if schedule.dom, err = parseField(fields[2], 1, 31); err != nil { 135 | return nil, err 136 | } 137 | if schedule.month, err = parseField(fields[3], 1, 12); err != nil { 138 | return nil, err 139 | } 140 | if schedule.dow, err = parseField(fields[4], 0, 6); err != nil { 141 | return nil, err 142 | } 143 | 144 | return schedule, nil 145 | } 146 | 147 | func parseField(field string, min, max int) (scheduleField, error) { 148 | if field == "*" { 149 | return scheduleField{All: true}, nil 150 | } 151 | 152 | if strings.HasPrefix(field, "*/") { 153 | str := field[2:] 154 | step, err := strconv.Atoi(str) 155 | if err != nil { 156 | return scheduleField{}, fmt.Errorf("invalid step value: %v", err) 157 | } 158 | if step <= 0 { 159 | return scheduleField{}, fmt.Errorf("step must greater than 0, got %d", step) 160 | } 161 | return scheduleField{Step: step}, nil 162 | } 163 | 164 | if strings.Contains(field, ",") { 165 | return parseList(field, min, max) 166 | } 167 | 168 | if strings.Contains(field, "-") { 169 | return parseRange(field, min, max) 170 | } 171 | 172 | value, err := strconv.Atoi(field) 173 | if err != nil { 174 | return scheduleField{}, fmt.Errorf("invalid value: %v", err) 175 | } 176 | 177 | if value < min || value > max { 178 | return scheduleField{}, fmt.Errorf("%d out of range [%d, %d]", value, min, max) 179 | } 180 | 181 | return scheduleField{Value: value}, nil 182 | } 183 | 184 | func parseRange(field string, min, max int) (scheduleField, error) { 185 | if strings.HasPrefix(field, "-") { 186 | return scheduleField{}, fmt.Errorf("cannot start with %s", field) 187 | } 188 | if strings.HasSuffix(field, "-") { 189 | return scheduleField{}, fmt.Errorf("cannot end with %s", field) 190 | } 191 | if strings.Contains(field, "--") { 192 | return scheduleField{}, fmt.Errorf("cannot contain multiple %s", field) 193 | } 194 | 195 | parts := strings.Split(field, "-") 196 | if len(parts) != 2 { 197 | return scheduleField{}, fmt.Errorf("invalid format: %s", field) 198 | } 199 | 200 | start, err := strconv.Atoi(strings.TrimSpace(parts[0])) 201 | if err != nil { 202 | return scheduleField{}, fmt.Errorf("invalid start: %v", err) 203 | } 204 | 205 | end, err := strconv.Atoi(strings.TrimSpace(parts[1])) 206 | if err != nil { 207 | return scheduleField{}, fmt.Errorf("invalid end: %v", err) 208 | } 209 | 210 | if start < min || start > max { 211 | return scheduleField{}, fmt.Errorf("%d out of bounds [%d, %d]", start, min, max) 212 | } 213 | if end < min || end > max { 214 | return scheduleField{}, fmt.Errorf("%d out of bounds [%d, %d]", end, min, max) 215 | } 216 | if start > end { 217 | return scheduleField{}, fmt.Errorf("%d cannot be greater than %d", start, end) 218 | } 219 | 220 | var values []int 221 | for i := start; i <= end; i++ { 222 | values = append(values, i) 223 | } 224 | 225 | return scheduleField{Values: values}, nil 226 | } 227 | 228 | func parseList(field string, min, max int) (scheduleField, error) { 229 | if strings.HasPrefix(field, ",") { 230 | return scheduleField{}, fmt.Errorf("cannot start with %s", field) 231 | } 232 | if strings.HasSuffix(field, ",") { 233 | return scheduleField{}, fmt.Errorf("cannot end with %s", field) 234 | } 235 | if strings.Contains(field, ",,") { 236 | return scheduleField{}, fmt.Errorf("cannot contain multiple %s", field) 237 | } 238 | 239 | parts := strings.Split(field, ",") 240 | var allValues []int 241 | valueSet := make(map[int]bool) 242 | 243 | for _, part := range parts { 244 | part = strings.TrimSpace(part) 245 | if part == "" { 246 | return scheduleField{}, fmt.Errorf("empty field %s", field) 247 | } 248 | 249 | var values []int 250 | 251 | if strings.Contains(part, "-") { 252 | rangeField, err := parseRange(part, min, max) 253 | if err != nil { 254 | return scheduleField{}, fmt.Errorf("invalid range %v", err) 255 | } 256 | values = rangeField.Values 257 | } else { 258 | value, err := strconv.Atoi(part) 259 | if err != nil { 260 | return scheduleField{}, fmt.Errorf("invalid value %v", err) 261 | } 262 | if value < min || value > max { 263 | return scheduleField{}, fmt.Errorf("%d out of range [%d, %d]", value, min, max) 264 | } 265 | values = []int{value} 266 | } 267 | 268 | for _, v := range values { 269 | if !valueSet[v] { 270 | valueSet[v] = true 271 | allValues = append(allValues, v) 272 | } 273 | } 274 | } 275 | 276 | if len(allValues) == 0 { 277 | return scheduleField{}, fmt.Errorf("empty list field: %s", field) 278 | } 279 | 280 | return scheduleField{Values: allValues}, nil 281 | } 282 | -------------------------------------------------------------------------------- /README.zh.md: -------------------------------------------------------------------------------- 1 | # Go 定時排程 2 | 3 | > 輕量的 Golang 排程器,支援標準 cron 表達式、自定義描述符、自訂間隔和任務依賴關係。輕鬆使用 Go 撰寫排程
4 | > 原本是設計給 [pardnchiu/go-ip-sentry](https://github.com/pardnchiu/go-ip-sentry) 威脅分數衰退計算所使用到的排程功能 5 | 6 | [![pkg](https://pkg.go.dev/badge/github.com/pardnchiu/go-scheduler.svg)](https://pkg.go.dev/github.com/pardnchiu/go-scheduler) 7 | [![card](https://goreportcard.com/badge/github.com/pardnchiu/go-scheduler)](https://goreportcard.com/report/github.com/pardnchiu/go-scheduler) 8 | [![codecov](https://img.shields.io/codecov/c/github/pardnchiu/go-scheduler)](https://app.codecov.io/github/pardnchiu/go-scheduler) 9 | [![version](https://img.shields.io/github/v/tag/pardnchiu/go-scheduler?label=release)](https://github.com/pardnchiu/go-scheduler/releases) 10 | [![license](https://img.shields.io/github/license/pardnchiu/go-scheduler)](LICENSE) 11 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go)
12 | [![readme](https://img.shields.io/badge/readme-EN-white)](README.md) 13 | [![readme](https://img.shields.io/badge/readme-ZH-white)](README.zh.md) 14 | 15 | - [三大核心特色](#三大核心特色) 16 | - [靈活語法](#靈活語法) 17 | - [任務依賴](#任務依賴) 18 | - [高效架構](#高效架構) 19 | - [流程圖](#流程圖) 20 | - [依賴套件](#依賴套件) 21 | - [使用方法](#使用方法) 22 | - [安裝](#安裝) 23 | - [初始化](#初始化) 24 | - [基本使用](#基本使用) 25 | - [任務依賴](#任務依賴-1) 26 | - [配置介紹](#配置介紹) 27 | - [支援格式](#支援格式) 28 | - [標準](#標準) 29 | - [自定義](#自定義) 30 | - [可用函式](#可用函式) 31 | - [排程管理](#排程管理) 32 | - [任務管理](#任務管理) 33 | - [任務依賴](#任務依賴-2) 34 | - [基本使用](#基本使用-1) 35 | - [依賴範例](#依賴範例) 36 | - [任務狀態](#任務狀態) 37 | - [超時機制](#超時機制) 38 | - [特點](#特點) 39 | - [功能預告](#功能預告) 40 | - [任務依賴增強](#任務依賴增強) 41 | - [任務完成觸發改寫](#任務完成觸發改寫) 42 | - [授權條款](#授權條款) 43 | - [星](#星) 44 | - [作者](#作者) 45 | 46 | ## 三大核心特色 47 | 48 | ### 靈活語法 49 | 支援標準 cron 表達式、自定義描述符(`@hourly`、`@daily`、`@weekly` 等)和自訂間隔(`@every`)語法,零學習成本,只要會寫 cron 表達式就基本會使用 50 | 51 | ### 任務依賴 52 | 支援前置依賴任務、多重依賴、依賴超時控制和失敗處理機制 53 | 54 | ### 高效架構 55 | 使用 Golang 標準庫的 `heap`,專注核心功能,基於最小堆的任務排程,併發的任務執行和管理,具有 panic 恢復機制和動態任務新增/移除功能,並確保在大量任務場景中的最佳效能 56 | 57 | ## 流程圖 58 | 59 |
60 | 主流程 61 | 62 | ```mermaid 63 | flowchart TD 64 | A[初始化] --> B{是否已執行?} 65 | B -->|否| B0[開始執行] 66 | B0 --> C[計算初始任務] 67 | C --> D[初始化任務] 68 | D --> E[啟動主迴圈] 69 | E --> H{檢查堆狀態} 70 | E -->|無任務
等待事件| Q 71 | E -->|有任務
設置下一任務計時器| Q 72 | B -->|是
等待觸發| Q[監聽事件] 73 | 74 | Q --> R{事件類型} 75 | R -->|計時器到期| R1[執行到期任務] 76 | R -->|新增任務| R2[加入堆] 77 | R -->|移除任務| R3[從堆移除] 78 | R -->|停止信號| R4[清理並退出] 79 | 80 | R1 --> S[從堆中彈出任務] 81 | S --> R5[計算下一次執行時間] 82 | R5 --> E 83 | S --> T{檢查是否啟用} 84 | T -->|未啟用| T0[跳過任務] 85 | T0 --> E 86 | T -->|啟用| T1[執行任務函數] 87 | 88 | R2 --> V[解析排程] 89 | V --> W[建立任務物件] 90 | W --> X[加入堆] 91 | 92 | R3 --> Y[根據 ID 查找任務] 93 | Y --> Z[標記為未啟用] 94 | Z --> AA[從堆移除] 95 | 96 | X --> E 97 | AA --> E 98 | 99 | R4 --> BB[等待執行中的任務完成] 100 | BB --> CC[關閉通道] 101 | CC --> DD[排程器已停止] 102 | ``` 103 | 104 |
105 | 106 |
107 | 依賴流程 108 | 109 | ```mermaid 110 | flowchart TD 111 | A[任務加入執行佇列] --> B{檢查依賴} 112 | B -->|無依賴| B0[跳過依賴流程] 113 | B0 --> Z[結束] 114 | B -->|有依賴| B1{依賴完成?} 115 | B1 -->|否| B10[等待依賴完成] 116 | B10 --> C{依賴等待超時?} 117 | C -->|否| C0[繼續等待] 118 | C0 --> D{依賴解決?} 119 | D -->|失敗
標記失敗| V 120 | D -->|完成| B11 121 | D -->|仍在等待| B10 122 | C -->|是
標記失敗| V 123 | B1 -->|是| B11[執行] 124 | B11 -->|標記執行中| E{任務超時存在?} 125 | E -->|否| E0[執行動作] 126 | E0 --> R{執行結果} 127 | R -->|成功
標記完成| V[更新任務結果] 128 | R -->|錯誤
標記失敗| V 129 | R -->|Panic
恢復並標記失敗| V 130 | E -->|是| E1{任務超時?} 131 | E1 -->|超時
標記失敗
觸發超時動作| V 132 | E1 -->|未超時| E0 133 | B1 -->|失敗
標記失敗| V 134 | 135 | V --> X[記錄執行結果] 136 | X --> Y[通知依賴任務] 137 | Y --> Z[結束] 138 | ``` 139 | 140 |
141 | 142 | ## 依賴套件 143 | 144 | - ~~[`github.com/pardnchiu/go-logger`](https://github.com/pardnchiu/go-logger)~~ (< v0.3.1)
145 | 為了效能與穩定度,`v0.3.1` 起棄用非標準庫套件,改用 `log/slog` 146 | 147 | ## 使用方法 148 | 149 | ### 安裝 150 | 151 | > [!NOTE] 152 | > 最新 commit 可能會變動,建議使用標籤版本
153 | > 針對僅包含文檔更新等非功能改動的 commit,後續會進行 rebase 154 | 155 | ```bash 156 | go get github.com/pardnchiu/go-scheduler@[VERSION] 157 | 158 | git clone --depth 1 --branch [VERSION] https://github.com/pardnchiu/go-scheduler.git 159 | ``` 160 | 161 | ### 初始化 162 | 163 | #### 基本使用 164 | ```go 165 | package main 166 | 167 | import ( 168 | "fmt" 169 | "log" 170 | "time" 171 | 172 | cron "github.com/pardnchiu/go-scheduler" 173 | ) 174 | 175 | func main() { 176 | // Initialize (optional configuration) 177 | scheduler, err := cron.New(cron.Config{ 178 | Location: time.Local, 179 | }) 180 | if err != nil { 181 | log.Fatal(err) 182 | } 183 | 184 | // Start scheduler 185 | scheduler.Start() 186 | 187 | // Add tasks 188 | id1, _ := scheduler.Add("@daily", func() { 189 | fmt.Println("Daily execution") 190 | }, "Backup task") 191 | 192 | id2, _ := scheduler.Add("@every 5m", func() { 193 | fmt.Println("Execute every 5 minutes") 194 | }) 195 | 196 | // View task list 197 | tasks := scheduler.List() 198 | fmt.Printf("Currently have %d tasks\n", len(tasks)) 199 | 200 | // Remove specific task 201 | scheduler.Remove(id1) 202 | 203 | // Remove all tasks 204 | scheduler.RemoveAll() 205 | 206 | // Graceful shutdown 207 | ctx := scheduler.Stop() 208 | <-ctx.Done() 209 | } 210 | ``` 211 | 212 | #### 任務依賴 213 | ```go 214 | package main 215 | 216 | import ( 217 | "fmt" 218 | "log" 219 | "time" 220 | 221 | cron "github.com/pardnchiu/go-scheduler" 222 | ) 223 | 224 | func main() { 225 | scheduler, err := cron.New(cron.Config{}) 226 | if err != nil { 227 | log.Fatal(err) 228 | } 229 | 230 | scheduler.Start() 231 | defer func() { 232 | ctx := scheduler.Stop() 233 | <-ctx.Done() 234 | }() 235 | 236 | // Task A: Data preparation 237 | taskA, _ := scheduler.Add("0 1 * * *", func() error { 238 | fmt.Println("Preparing data...") 239 | time.Sleep(2 * time.Second) 240 | return nil 241 | }, "Data preparation") 242 | 243 | // Task B: Data processing 244 | taskB, _ := scheduler.Add("0 2 * * *", func() error { 245 | fmt.Println("Processing data...") 246 | time.Sleep(3 * time.Second) 247 | return nil 248 | }, "Data processing") 249 | 250 | // Task C: Report generation (depends on A and B) 251 | taskC, _ := scheduler.Add("0 3 * * *", func() error { 252 | fmt.Println("Generating report...") 253 | time.Sleep(1 * time.Second) 254 | return nil 255 | }, "Report generation", []Wait{{ID: taskA}, {ID: taskB}}) 256 | 257 | // Task D: Email sending (depends on C) 258 | _, _ = scheduler.Add("0 4 * * *", func() error { 259 | fmt.Println("Sending email...") 260 | return nil 261 | }, "Email notification", []Wait{{ID: taskC}}) 262 | 263 | time.Sleep(10 * time.Second) 264 | } 265 | ``` 266 | 267 | ## 配置介紹 268 | ```go 269 | type Config struct { 270 | Location *time.Location // Timezone setting (default: time.Local) 271 | } 272 | ``` 273 | 274 | ## 支援格式 275 | 276 | ### 標準 277 | > 5 欄位格式:`分鐘 小時 日 月 星期`
278 | > 支援範圍語法 `1-5` 和 `1,3,5` 279 | 280 | ```go 281 | // Every minute 282 | scheduler.Add("* * * * *", task) 283 | 284 | // Daily at midnight 285 | scheduler.Add("0 0 * * *", task) 286 | 287 | // Every 15 minutes 288 | scheduler.Add("*/15 * * * *", task) 289 | 290 | // First day of month at 6 AM 291 | scheduler.Add("0 6 1 * *", task) 292 | 293 | // Monday to Wednesday, and Friday 294 | scheduler.Add("0 0 * * 1-3,5", task) 295 | ``` 296 | 297 | ### 自定義 298 | ```go 299 | // January 1st at midnight 300 | scheduler.Add("@yearly", task) 301 | 302 | // First day of month at midnight 303 | scheduler.Add("@monthly", task) 304 | 305 | // Every Sunday at midnight 306 | scheduler.Add("@weekly", task) 307 | 308 | // Daily at midnight 309 | scheduler.Add("@daily", task) 310 | 311 | // Every hour on the hour 312 | scheduler.Add("@hourly", task) 313 | 314 | // Every 30 seconds (minimum interval: 30 seconds) 315 | scheduler.Add("@every 30s", task) 316 | 317 | // Every 5 minutes 318 | scheduler.Add("@every 5m", task) 319 | 320 | // Every 2 hours 321 | scheduler.Add("@every 2h", task) 322 | 323 | // Every 12 hours 324 | scheduler.Add("@every 12h", task) 325 | ``` 326 | 327 | ## 可用函式 328 | 329 | ### 排程管理 330 | 331 | - `New()` - 建立新的排程實例 332 | ```go 333 | scheduler, err := cron.New(config) 334 | ``` 335 | - 設置任務堆和通訊通道 336 | 337 | - `Start()` - 啟動排程實例 338 | ```go 339 | scheduler.Start() 340 | ``` 341 | - 啟動排程迴圈 342 | 343 | - `Stop()` - 停止排程器 344 | ```go 345 | ctx := scheduler.Stop() 346 | <-ctx.Done() // Wait for all tasks to complete 347 | ``` 348 | - 向主迴圈發送停止信號 349 | - 回傳在所有執行中任務完成時完成的 context 350 | - 確保不中斷任務的關閉 351 | 352 | ### 任務管理 353 | 354 | - `Add()` - 新增排程任務 355 | ```go 356 | // Basic usage (no return value) 357 | taskID, err := scheduler.Add("0 */2 * * *", func() { 358 | // Task logic 359 | }) 360 | 361 | // Task with error return (supports dependencies) 362 | taskID, err := scheduler.Add("@daily", func() error { 363 | // Task logic 364 | return nil 365 | }, "Backup task") 366 | 367 | // Task with timeout control 368 | taskID, err := scheduler.Add("@hourly", func() error { 369 | // Long-running task 370 | time.Sleep(10 * time.Second) 371 | return nil 372 | }, "Data processing", 5*time.Second) 373 | 374 | // Task with timeout callback 375 | taskID, err := scheduler.Add("@daily", func() error { 376 | // Potentially timeout-prone task 377 | return heavyProcessing() 378 | }, "Critical backup", 30*time.Second, func() { 379 | log.Println("Backup task timed out, please check system status") 380 | }) 381 | 382 | // Task with dependencies 383 | taskID, err := scheduler.Add("@daily", func() error { 384 | // Task that depends on other tasks 385 | return processData() 386 | }, "Data processing", []Wait{{ID: taskA}, {ID: taskB}}) 387 | 388 | // Task with dependencies and timeout 389 | taskID, err := scheduler.Add("@daily", func() error { 390 | return generateReport() 391 | }, "Report generation", []Wait{ 392 | {ID: taskA, Delay: 30 * time.Second}, 393 | {ID: taskB, Delay: 45 * time.Second}, 394 | }) 395 | ``` 396 | - 解析排程語法 397 | - 產生唯一的任務 ID 以便管理 398 | - 支援可變參數配置 399 | - `string`:任務描述 400 | - `time.Duration`:任務執行超時時間 401 | - `func()`:超時觸發的回調函式 402 | - `[]Wait`:依賴任務配置(推薦格式) 403 | - `[]int64`:依賴任務 ID 列表(v2.0 後將移除) 404 | - 支援兩種動作函式 405 | - `func()`:無錯誤返回,不支援依賴 406 | - `func() error`:有錯誤返回,支援依賴 407 | 408 | - `Remove()` - 取消任務排程 409 | ```go 410 | scheduler.Remove(taskID) 411 | ``` 412 | - 從排程佇列中移除任務 413 | - 無論排程器狀態如何都可安全呼叫 414 | 415 | - `RemoveAll()` - 移除所有任務 416 | ```go 417 | scheduler.RemoveAll() 418 | ``` 419 | - 立即移除所有排程任務 420 | - 不影響正在執行的任務 421 | 422 | - `List()` - 獲取任務列表 423 | ```go 424 | tasks := scheduler.List() 425 | ``` 426 | 427 | ## 任務依賴 428 | 429 | ### 基本使用 430 | - 無依賴:直接執行 431 | - 有依賴:透過 worker 池和依賴管理器執行 432 | - 單一依賴:任務 B 在任務 A 完成後執行 433 | - 多重依賴:任務 C 等待任務 A、B 全部完成後執行 434 | - 依賴任務超時:等待依賴任務完成的最大時間(預設 1 分鐘) 435 | 436 | ### 依賴範例 437 | 438 | **失敗處理策略**: 439 | ```go 440 | // Skip:依賴失敗時跳過並繼續執行 441 | taskC, _ := scheduler.Add("0 3 * * *", func() error { 442 | fmt.Println("Generating report...") 443 | return nil 444 | }, "Report generation", []Wait{ 445 | {ID: taskA, State: Skip}, // taskA 失敗時跳過 446 | {ID: taskB, State: Stop}, // taskB 失敗時停止(預設) 447 | }) 448 | ``` 449 | 450 | **自定義超時時間**: 451 | ```go 452 | // 為每個依賴設定獨立的等待時間 453 | taskC, _ := scheduler.Add("0 3 * * *", func() error { 454 | fmt.Println("Generating report...") 455 | return nil 456 | }, "Report generation", []Wait{ 457 | {ID: taskA, Delay: 30 * time.Second}, // 等待 30 秒 458 | {ID: taskB, Delay: 45 * time.Second}, // 等待 45 秒 459 | }) 460 | ``` 461 | 462 | **組合使用**: 463 | ```go 464 | // 結合失敗策略和自定義超時 465 | taskC, _ := scheduler.Add("0 3 * * *", func() error { 466 | fmt.Println("Generating report...") 467 | return nil 468 | }, "Report generation", []Wait{ 469 | {ID: taskA, Delay: 30 * time.Second, State: Skip}, 470 | {ID: taskB, Delay: 45 * time.Second, State: Stop}, 471 | }) 472 | ``` 473 | 474 | ### 任務狀態 475 | ```go 476 | const ( 477 | TaskPending // Waiting 478 | TaskRunning // Running 479 | TaskCompleted // Completed 480 | TaskFailed // Failed / Timeout 481 | ) 482 | ``` 483 | 484 | ## 超時機制 485 | 當執行時間超過設定的 `Delay` 486 | - 中斷任務執行 487 | - 觸發 `OnDelay` 函式(如果有設定) 488 | - 記錄超時日誌 489 | - 繼續執行下一個排程 490 | 491 | ### 特點 492 | - 超時使用 `context.WithTimeout` 實現 493 | - 超時不會影響其他任務的執行 494 | - 如果動作在超時前完成,不會觸發超時 495 | 496 | ## 功能評估 497 | 498 | ### 任務依賴增強 499 | 500 | - 狀態回調:新增 `OnTimeout` 和 `OnFailed` 回調函數,方便監控和響應依賴任務的異常狀態 501 | 502 | ### 任務完成觸發改寫 503 | 504 | - 事件驅動:將當前的輪詢改為完全基於 `channel` 的模式,降低 CPU 使用率 505 | - 依賴喚醒:實作依賴任務完成時的主動通知機制,消除無效的輪詢檢查 506 | 507 | ## 授權條款 508 | 509 | 此專案採用 [MIT](LICENSE) 授權條款。 510 | 511 | ## 星 512 | 513 | [![Star](https://api.star-history.com/svg?repos=pardnchiu/go-scheduler&type=Date)](https://www.star-history.com/#pardnchiu/go-scheduler&Date) 514 | 515 | ## 作者 516 | 517 | 518 | 519 |

邱敬幃 Pardn Chiu

520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | *** 528 | 529 | ©️ 2025 [邱敬幃 Pardn Chiu](https://pardn.io) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!NOTE] 2 | > This README was translated by ChatGPT 4o 3 | # Go Scheduler 4 | 5 | > Lightweight Golang scheduler supporting standard cron expressions, custom descriptors, custom intervals, and task dependencies. Easy scheduling with Go
6 | > Originally designed for the scheduling functionality used in threat score decay calculations for [pardnchiu/go-ip-sentry](https://github.com/pardnchiu/go-ip-sentry) 7 | 8 | [![pkg](https://pkg.go.dev/badge/github.com/pardnchiu/go-scheduler.svg)](https://pkg.go.dev/github.com/pardnchiu/go-scheduler) 9 | [![card](https://goreportcard.com/badge/github.com/pardnchiu/go-scheduler)](https://goreportcard.com/report/github.com/pardnchiu/go-scheduler) 10 | [![codecov](https://img.shields.io/codecov/c/github/pardnchiu/go-scheduler)](https://app.codecov.io/github/pardnchiu/go-scheduler) 11 | [![version](https://img.shields.io/github/v/tag/pardnchiu/go-scheduler?label=release)](https://github.com/pardnchiu/go-scheduler/releases) 12 | [![license](https://img.shields.io/github/license/pardnchiu/go-scheduler)](LICENSE) 13 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go)
14 | [![readme](https://img.shields.io/badge/readme-EN-white)](README.md) 15 | [![readme](https://img.shields.io/badge/readme-ZH-white)](README.zh.md) 16 | 17 | - [Core Features](#core-features) 18 | - [Flexible Syntax](#flexible-syntax) 19 | - [Task Dependencies](#task-dependencies) 20 | - [Efficient Architecture](#efficient-architecture) 21 | - [Flowchart](#flowchart) 22 | - [Dependencies](#dependencies) 23 | - [Usage](#usage) 24 | - [Installation](#installation) 25 | - [Initialization](#initialization) 26 | - [Basic Usage](#basic-usage) 27 | - [Task Dependencies](#task-dependencies-1) 28 | - [Configuration](#configuration) 29 | - [Supported Formats](#supported-formats) 30 | - [Standard](#standard) 31 | - [Custom](#custom) 32 | - [Available Functions](#available-functions) 33 | - [Scheduler Management](#scheduler-management) 34 | - [Task Management](#task-management) 35 | - [Task Dependencies](#task-dependencies-2) 36 | - [Basic Usage](#basic-usage-1) 37 | - [Dependency Examples](#dependency-examples) 38 | - [Task Status](#task-status) 39 | - [Timeout Mechanism](#timeout-mechanism) 40 | - [Features](#features) 41 | - [Upcoming Features](#upcoming-features) 42 | - [Enhanced Task Dependencies](#enhanced-task-dependencies) 43 | - [Task Completion Trigger Refactor](#task-completion-trigger-refactor) 44 | - [License](#license) 45 | - [Star](#star) 46 | - [Author](#author) 47 | 48 | ## Core Features 49 | 50 | ### Flexible Syntax 51 | Supports standard cron expressions, custom descriptors (`@hourly`, `@daily`, `@weekly`, etc.), and custom interval (`@every`) syntax. Zero learning curve - if you know cron expressions, you can use it. 52 | 53 | ### Task Dependencies 54 | Supports prerequisite dependency tasks, multiple dependencies, dependency timeout control, and failure handling mechanisms. 55 | 56 | ### Efficient Architecture 57 | Uses Golang standard library `heap`, focusing on core functionality. Min-heap based task scheduling with concurrent task execution and management, featuring panic recovery and dynamic task add/remove capabilities, ensuring optimal performance in high-volume task scenarios. 58 | 59 | ## Flowchart 60 | 61 |
62 | Main Process 63 | 64 | ```mermaid 65 | flowchart TD 66 | A[Initialize] --> B{Is Running?} 67 | B -->|No| B0[Start Execution] 68 | B0 --> C[Calculate Initial Tasks] 69 | C --> D[Initialize Tasks] 70 | D --> E[Start Main Loop] 71 | E --> H{Check Heap Status} 72 | E -->|No Tasks
Wait for Events| Q 73 | E -->|Has Tasks
Set Next Task Timer| Q 74 | B -->|Yes
Wait for Trigger| Q[Listen Events] 75 | 76 | Q --> R{Event Type} 77 | R -->|Timer Expired| R1[Execute Due Tasks] 78 | R -->|Add Task| R2[Add to Heap] 79 | R -->|Remove Task| R3[Remove from Heap] 80 | R -->|Stop Signal| R4[Cleanup and Exit] 81 | 82 | R1 --> S[Pop Task from Heap] 83 | S --> R5[Calculate Next Execution Time] 84 | R5 --> E 85 | S --> T{Check if Enabled} 86 | T -->|Disabled| T0[Skip Task] 87 | T0 --> E 88 | T -->|Enabled| T1[Execute Task Function] 89 | 90 | R2 --> V[Parse Schedule] 91 | V --> W[Create Task Object] 92 | W --> X[Add to Heap] 93 | 94 | R3 --> Y[Find Task by ID] 95 | Y --> Z[Mark as Disabled] 96 | Z --> AA[Remove from Heap] 97 | 98 | X --> E 99 | AA --> E 100 | 101 | R4 --> BB[Wait for Running Tasks to Complete] 102 | BB --> CC[Close Channels] 103 | CC --> DD[Scheduler Stopped] 104 | ``` 105 | 106 |
107 | 108 |
109 | Dependency Process 110 | 111 | ```mermaid 112 | flowchart TD 113 | A[Task Added to Execution Queue] --> B{Check Dependencies} 114 | B -->|No Dependencies| B0[Skip Dependency Process] 115 | B0 --> Z[End] 116 | B -->|Has Dependencies| B1{Dependencies Complete?} 117 | B1 -->|No| B10[Wait for Dependencies] 118 | B10 --> C{Dependency Wait Timeout?} 119 | C -->|No| C0[Continue Waiting] 120 | C0 --> D{Dependency Resolved?} 121 | D -->|Failed
Mark Failed| V 122 | D -->|Completed| B11 123 | D -->|Still Waiting| B10 124 | C -->|Yes
Mark Failed| V 125 | B1 -->|Yes| B11[Execute] 126 | B11 -->|Mark Running| E{Task Timeout Exists?} 127 | E -->|No| E0[Execute Action] 128 | E0 --> R{Execution Result} 129 | R -->|Success
Mark Complete| V[Update Task Result] 130 | R -->|Error
Mark Failed| V 131 | R -->|Panic
Recover and Mark Failed| V 132 | E -->|Yes| E1{Task Timeout?} 133 | E1 -->|Timeout
Mark Failed
Trigger Timeout Action| V 134 | E1 -->|Not Timeout| E0 135 | B1 -->|Failed
Mark Failed| V 136 | 137 | V --> X[Record Execution Result] 138 | X --> Y[Notify Dependent Tasks] 139 | Y --> Z[End] 140 | ``` 141 | 142 |
143 | 144 | ## Dependencies 145 | 146 | - ~~[`github.com/pardnchiu/go-logger`](https://github.com/pardnchiu/go-logger)~~ (< v0.3.1)
147 | For performance and stability, non-standard library packages are deprecated from `v0.3.1`, now using `log/slog` 148 | 149 | ## Usage 150 | 151 | ### Installation 152 | 153 | > [!NOTE] 154 | > Latest commit may change, recommended to use tagged versions
155 | > Commits containing only documentation updates or non-functional changes will be rebased later 156 | 157 | ```bash 158 | go get github.com/pardnchiu/go-scheduler@[VERSION] 159 | 160 | git clone --depth 1 --branch [VERSION] https://github.com/pardnchiu/go-scheduler.git 161 | ``` 162 | 163 | ### Initialization 164 | 165 | #### Basic Usage 166 | ```go 167 | package main 168 | 169 | import ( 170 | "fmt" 171 | "log" 172 | "time" 173 | 174 | cron "github.com/pardnchiu/go-scheduler" 175 | ) 176 | 177 | func main() { 178 | // Initialize (optional configuration) 179 | scheduler, err := cron.New(cron.Config{ 180 | Location: time.Local, 181 | }) 182 | if err != nil { 183 | log.Fatal(err) 184 | } 185 | 186 | // Start scheduler 187 | scheduler.Start() 188 | 189 | // Add tasks 190 | id1, _ := scheduler.Add("@daily", func() { 191 | fmt.Println("Daily execution") 192 | }, "Backup task") 193 | 194 | id2, _ := scheduler.Add("@every 5m", func() { 195 | fmt.Println("Execute every 5 minutes") 196 | }) 197 | 198 | // View task list 199 | tasks := scheduler.List() 200 | fmt.Printf("Currently have %d tasks\n", len(tasks)) 201 | 202 | // Remove specific task 203 | scheduler.Remove(id1) 204 | 205 | // Remove all tasks 206 | scheduler.RemoveAll() 207 | 208 | // Graceful shutdown 209 | ctx := scheduler.Stop() 210 | <-ctx.Done() 211 | } 212 | ``` 213 | 214 | #### Task Dependencies 215 | ```go 216 | package main 217 | 218 | import ( 219 | "fmt" 220 | "log" 221 | "time" 222 | 223 | cron "github.com/pardnchiu/go-scheduler" 224 | ) 225 | 226 | func main() { 227 | scheduler, err := cron.New(cron.Config{}) 228 | if err != nil { 229 | log.Fatal(err) 230 | } 231 | 232 | scheduler.Start() 233 | defer func() { 234 | ctx := scheduler.Stop() 235 | <-ctx.Done() 236 | }() 237 | 238 | // Task A: Data preparation 239 | taskA, _ := scheduler.Add("0 1 * * *", func() error { 240 | fmt.Println("Preparing data...") 241 | time.Sleep(2 * time.Second) 242 | return nil 243 | }, "Data preparation") 244 | 245 | // Task B: Data processing 246 | taskB, _ := scheduler.Add("0 2 * * *", func() error { 247 | fmt.Println("Processing data...") 248 | time.Sleep(3 * time.Second) 249 | return nil 250 | }, "Data processing") 251 | 252 | // Task C: Report generation (depends on A and B) 253 | taskC, _ := scheduler.Add("0 3 * * *", func() error { 254 | fmt.Println("Generating report...") 255 | time.Sleep(1 * time.Second) 256 | return nil 257 | }, "Report generation", []Wait{{ID: taskA}, {ID: taskB}}) 258 | 259 | // Task D: Email sending (depends on C) 260 | _, _ = scheduler.Add("0 4 * * *", func() error { 261 | fmt.Println("Sending email...") 262 | return nil 263 | }, "Email notification", []Wait{{ID: taskC}}) 264 | 265 | time.Sleep(10 * time.Second) 266 | } 267 | ``` 268 | 269 | ## Configuration 270 | ```go 271 | type Config struct { 272 | Location *time.Location // Timezone setting (default: time.Local) 273 | } 274 | ``` 275 | 276 | ## Supported Formats 277 | 278 | ### Standard 279 | > 5-field format: `minute hour day month weekday`
280 | > Supports range syntax `1-5` and `1,3,5` 281 | 282 | ```go 283 | // Every minute 284 | scheduler.Add("* * * * *", task) 285 | 286 | // Daily at midnight 287 | scheduler.Add("0 0 * * *", task) 288 | 289 | // Every 15 minutes 290 | scheduler.Add("*/15 * * * *", task) 291 | 292 | // First day of month at 6 AM 293 | scheduler.Add("0 6 1 * *", task) 294 | 295 | // Monday to Wednesday, and Friday 296 | scheduler.Add("0 0 * * 1-3,5", task) 297 | ``` 298 | 299 | ### Custom 300 | ```go 301 | // January 1st at midnight 302 | scheduler.Add("@yearly", task) 303 | 304 | // First day of month at midnight 305 | scheduler.Add("@monthly", task) 306 | 307 | // Every Sunday at midnight 308 | scheduler.Add("@weekly", task) 309 | 310 | // Daily at midnight 311 | scheduler.Add("@daily", task) 312 | 313 | // Every hour on the hour 314 | scheduler.Add("@hourly", task) 315 | 316 | // Every 30 seconds (minimum interval: 30 seconds) 317 | scheduler.Add("@every 30s", task) 318 | 319 | // Every 5 minutes 320 | scheduler.Add("@every 5m", task) 321 | 322 | // Every 2 hours 323 | scheduler.Add("@every 2h", task) 324 | 325 | // Every 12 hours 326 | scheduler.Add("@every 12h", task) 327 | ``` 328 | 329 | ## Available Functions 330 | 331 | ### Scheduler Management 332 | 333 | - `New()` - Create new scheduler instance 334 | ```go 335 | scheduler, err := cron.New(config) 336 | ``` 337 | - Sets up task heap and communication channels 338 | 339 | - `Start()` - Start scheduler instance 340 | ```go 341 | scheduler.Start() 342 | ``` 343 | - Starts the scheduling loop 344 | 345 | - `Stop()` - Stop scheduler 346 | ```go 347 | ctx := scheduler.Stop() 348 | <-ctx.Done() // Wait for all tasks to complete 349 | ``` 350 | - Sends stop signal to main loop 351 | - Returns context that completes when all running tasks finish 352 | - Ensures graceful shutdown without interrupting tasks 353 | 354 | ### Task Management 355 | 356 | - `Add()` - Add scheduled task 357 | ```go 358 | // Basic usage (no return value) 359 | taskID, err := scheduler.Add("0 */2 * * *", func() { 360 | // Task logic 361 | }) 362 | 363 | // Task with error return (supports dependencies) 364 | taskID, err := scheduler.Add("@daily", func() error { 365 | // Task logic 366 | return nil 367 | }, "Backup task") 368 | 369 | // Task with timeout control 370 | taskID, err := scheduler.Add("@hourly", func() error { 371 | // Long-running task 372 | time.Sleep(10 * time.Second) 373 | return nil 374 | }, "Data processing", 5*time.Second) 375 | 376 | // Task with timeout callback 377 | taskID, err := scheduler.Add("@daily", func() error { 378 | // Potentially timeout-prone task 379 | return heavyProcessing() 380 | }, "Critical backup", 30*time.Second, func() { 381 | log.Println("Backup task timed out, please check system status") 382 | }) 383 | 384 | // Task with dependencies 385 | taskID, err := scheduler.Add("@daily", func() error { 386 | // Task that depends on other tasks 387 | return processData() 388 | }, "Data processing", []Wait{{ID: taskA}, {ID: taskB}}) 389 | 390 | // Task with dependencies and timeout 391 | taskID, err := scheduler.Add("@daily", func() error { 392 | return generateReport() 393 | }, "Report generation", []Wait{ 394 | {ID: taskA, Delay: 30 * time.Second}, 395 | {ID: taskB, Delay: 45 * time.Second}, 396 | }) 397 | ``` 398 | - Parses schedule syntax 399 | - Generates unique task ID for management 400 | - Supports variadic parameter configuration 401 | - `string`: Task description 402 | - `time.Duration`: Task execution timeout 403 | - `func()`: Callback function triggered on timeout 404 | - `[]Wait`: Dependency task configuration (recommended format) 405 | - `[]int64`: Dependency task ID list (will be removed after v2.0) 406 | - Supports two action function types 407 | - `func()`: No error return, doesn't support dependencies 408 | - `func() error`: Has error return, supports dependencies 409 | 410 | - `Remove()` - Cancel task schedule 411 | ```go 412 | scheduler.Remove(taskID) 413 | ``` 414 | - Removes task from scheduling queue 415 | - Safe to call regardless of scheduler state 416 | 417 | - `RemoveAll()` - Remove all tasks 418 | ```go 419 | scheduler.RemoveAll() 420 | ``` 421 | - Immediately removes all scheduled tasks 422 | - Doesn't affect currently running tasks 423 | 424 | - `List()` - Get task list 425 | ```go 426 | tasks := scheduler.List() 427 | ``` 428 | 429 | ## Task Dependencies 430 | 431 | ### Basic Usage 432 | - No dependencies: Execute directly 433 | - Has dependencies: Execute through worker pool and dependency manager 434 | - Single dependency: Task B executes after Task A completes 435 | - Multiple dependencies: Task C waits for both Task A and B to complete 436 | - Dependency task timeout: Maximum time to wait for dependency completion (default 1 minute) 437 | 438 | ### Dependency Examples 439 | 440 | **Failure handling strategies**: 441 | ```go 442 | // Skip: Continue execution when dependency fails 443 | taskC, _ := scheduler.Add("0 3 * * *", func() error { 444 | fmt.Println("Generating report...") 445 | return nil 446 | }, "Report generation", []Wait{ 447 | {ID: taskA, State: Skip}, // Skip when taskA fails 448 | {ID: taskB, State: Stop}, // Stop when taskB fails (default) 449 | }) 450 | ``` 451 | 452 | **Custom timeout**: 453 | ```go 454 | // Set independent wait time for each dependency 455 | taskC, _ := scheduler.Add("0 3 * * *", func() error { 456 | fmt.Println("Generating report...") 457 | return nil 458 | }, "Report generation", []Wait{ 459 | {ID: taskA, Delay: 30 * time.Second}, // Wait 30 seconds 460 | {ID: taskB, Delay: 45 * time.Second}, // Wait 45 seconds 461 | }) 462 | ``` 463 | 464 | **Combined usage**: 465 | ```go 466 | // Combine failure strategies with custom timeout 467 | taskC, _ := scheduler.Add("0 3 * * *", func() error { 468 | fmt.Println("Generating report...") 469 | return nil 470 | }, "Report generation", []Wait{ 471 | {ID: taskA, Delay: 30 * time.Second, State: Skip}, 472 | {ID: taskB, Delay: 45 * time.Second, State: Stop}, 473 | }) 474 | ``` 475 | 476 | ### Task Status 477 | ```go 478 | const ( 479 | TaskPending // Waiting 480 | TaskRunning // Running 481 | TaskCompleted // Completed 482 | TaskFailed // Failed / Timeout 483 | ) 484 | ``` 485 | 486 | ## Timeout Mechanism 487 | When execution time exceeds the set `Delay`: 488 | - Interrupts task execution 489 | - Triggers `OnDelay` function (if set) 490 | - Logs timeout 491 | - Continues with next schedule 492 | 493 | ### Features 494 | - Timeout implemented using `context.WithTimeout` 495 | - Timeout doesn't affect other task execution 496 | - If action completes before timeout, timeout won't trigger 497 | 498 | ## Upcoming Features 499 | 500 | ### Enhanced Task Dependencies 501 | 502 | - Status callbacks: Add `OnTimeout` and `OnFailed` callback functions for monitoring and responding to dependency task exception states 503 | 504 | ### Task Completion Trigger Refactor 505 | 506 | - Event-driven: Replace current polling with fully `channel`-based approach to reduce CPU usage 507 | - Dependency awakening: Implement active notification mechanism when dependency tasks complete, eliminating ineffective polling checks 508 | 509 | ## License 510 | 511 | This project is licensed under [MIT](LICENSE). 512 | 513 | ## Star 514 | 515 | [![Star](https://api.star-history.com/svg?repos=pardnchiu/go-scheduler&type=Date)](https://www.star-history.com/#pardnchiu/go-scheduler&Date) 516 | 517 | ## Author 518 | 519 | 520 | 521 |

邱敬幃 Pardn Chiu

522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | *** 530 | 531 | ©️ 2025 [邱敬幃 Pardn Chiu](https://pardn.io) -------------------------------------------------------------------------------- /cron_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Test code generated by GitHub Copilot with Claude 4 3 | */ 4 | package goCron 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | // Test Configuration 18 | var testConfig = Config{ 19 | Location: time.Local, 20 | } 21 | 22 | // Helper Functions 23 | 24 | func createTestCron(t *testing.T) *cron { 25 | t.Helper() 26 | 27 | c, err := New(testConfig) 28 | require.NoError(t, err, "Failed to create cron instance") 29 | require.NotNil(t, c, "Cron instance should not be nil") 30 | 31 | return c 32 | } 33 | 34 | func cleanupCron(t *testing.T, c *cron) { 35 | t.Helper() 36 | 37 | if c != nil { 38 | ctx := c.Stop() 39 | select { 40 | case <-ctx.Done(): 41 | case <-time.After(3 * time.Second): // 減少等待時間 42 | t.Error("Cron cleanup timeout") 43 | } 44 | } 45 | } 46 | 47 | // 新增:快速測試助手函數 48 | func createAndStartCron(t *testing.T) *cron { 49 | t.Helper() 50 | c := createTestCron(t) 51 | c.Start() 52 | return c 53 | } 54 | 55 | // Unit Tests 56 | 57 | // TestCron_New 測試實例創建 58 | func TestCron_New(t *testing.T) { 59 | tests := []struct { 60 | name string 61 | config Config 62 | wantErr bool 63 | }{ 64 | { 65 | name: "default config", 66 | config: Config{}, 67 | wantErr: false, 68 | }, 69 | { 70 | name: "with location", 71 | config: Config{ 72 | Location: time.UTC, 73 | }, 74 | wantErr: false, 75 | }, 76 | { 77 | name: "nil location should use default", 78 | config: Config{ 79 | Location: nil, 80 | }, 81 | wantErr: false, 82 | }, 83 | } 84 | 85 | for _, tt := range tests { 86 | t.Run(tt.name, func(t *testing.T) { 87 | c, err := New(tt.config) 88 | 89 | if tt.wantErr { 90 | assert.Error(t, err) 91 | assert.Nil(t, c) 92 | } else { 93 | assert.NoError(t, err) 94 | assert.NotNil(t, c) 95 | 96 | // Verify default location is set 97 | if tt.config.Location == nil { 98 | assert.Equal(t, time.Local, c.location) 99 | } else { 100 | assert.Equal(t, tt.config.Location, c.location) 101 | } 102 | } 103 | }) 104 | } 105 | } 106 | 107 | // TestCron_Initialization 測試基本初始化 108 | func TestCron_Initialization(t *testing.T) { 109 | c := createTestCron(t) 110 | defer cleanupCron(t, c) 111 | 112 | assert.NotNil(t, c.heap, "Task heap should be initialized") 113 | assert.NotNil(t, c.parser, "Parser should be initialized") 114 | assert.NotNil(t, c.stop, "Stop channel should be initialized") 115 | assert.NotNil(t, c.add, "Add channel should be initialized") 116 | assert.NotNil(t, c.remove, "Remove channel should be initialized") 117 | assert.NotNil(t, c.removeAll, "RemoveAll channel should be initialized") 118 | assert.NotNil(t, c.depend, "Depend manager should be initialized") 119 | assert.False(t, c.running, "Should not be running initially") 120 | } 121 | 122 | // TestCron_Add 測試任務添加 123 | func TestCron_Add(t *testing.T) { 124 | c := createTestCron(t) 125 | defer cleanupCron(t, c) 126 | 127 | tests := []struct { 128 | name string 129 | spec string 130 | action interface{} 131 | args []interface{} 132 | wantErr bool 133 | errorMsg string 134 | }{ 135 | { 136 | name: "valid func() task", 137 | spec: "@every 30s", 138 | action: func() {}, 139 | wantErr: false, 140 | }, 141 | { 142 | name: "valid func() error task", 143 | spec: "@every 30s", 144 | action: func() error { return nil }, 145 | wantErr: false, 146 | }, 147 | { 148 | name: "valid task with description", 149 | spec: "@every 30s", 150 | action: func() {}, 151 | args: []interface{}{"test task"}, 152 | wantErr: false, 153 | }, 154 | { 155 | name: "valid task with timeout", 156 | spec: "@every 30s", 157 | action: func() error { return nil }, 158 | args: []interface{}{5 * time.Second}, 159 | wantErr: false, 160 | }, 161 | { 162 | name: "valid task with timeout callback", 163 | spec: "@every 30s", 164 | action: func() error { return nil }, 165 | args: []interface{}{5 * time.Second, func() {}}, 166 | wantErr: false, 167 | }, 168 | { 169 | name: "invalid schedule", 170 | spec: "invalid-cron-expression", 171 | action: func() {}, 172 | wantErr: true, 173 | errorMsg: "failed to parse", 174 | }, 175 | { 176 | name: "too frequent interval", 177 | spec: "@every 1s", 178 | action: func() {}, 179 | wantErr: true, 180 | errorMsg: "minimum interval is 30s", 181 | }, 182 | { 183 | name: "invalid action type", 184 | spec: "@every 30s", 185 | action: "not a function", 186 | wantErr: true, 187 | errorMsg: "action need to be func()", 188 | }, 189 | { 190 | name: "func() with dependencies should fail", 191 | spec: "@every 30s", 192 | action: func() {}, 193 | args: []interface{}{[]Wait{{ID: 1}}}, 194 | wantErr: true, 195 | errorMsg: "need return value to get dependence support", 196 | }, 197 | { 198 | name: "task with Wait dependencies", 199 | spec: "@every 30s", 200 | action: func() error { return nil }, 201 | args: []interface{}{[]Wait{{ID: 1, State: Skip}}}, 202 | wantErr: false, 203 | }, 204 | } 205 | 206 | for _, tt := range tests { 207 | t.Run(tt.name, func(t *testing.T) { 208 | id, err := c.Add(tt.spec, tt.action, tt.args...) 209 | 210 | if tt.wantErr { 211 | assert.Error(t, err) 212 | assert.Contains(t, err.Error(), tt.errorMsg) 213 | assert.Equal(t, int64(0), id) 214 | } else { 215 | assert.NoError(t, err) 216 | assert.Greater(t, id, int64(0)) 217 | } 218 | }) 219 | } 220 | } 221 | 222 | // TestCron_StandardCronSyntax 測試標準 cron 語法 223 | func TestCron_StandardCronSyntax(t *testing.T) { 224 | c := createTestCron(t) 225 | defer cleanupCron(t, c) 226 | 227 | validExpressions := []string{ 228 | "0 * * * *", // Every hour 229 | "30 2 * * *", // Daily at 2:30 AM 230 | "0 9 * * 1", // Mondays at 9 AM 231 | "*/15 * * * *", // Every 15 minutes 232 | "0 0 1 * *", // First day of month 233 | "0 0 * * 1-5", // Weekdays 234 | "0 0 * * 1,3,5", // Mon, Wed, Fri 235 | } 236 | 237 | for _, expr := range validExpressions { 238 | t.Run(fmt.Sprintf("cron_%s", expr), func(t *testing.T) { 239 | id, err := c.Add(expr, func() {}, fmt.Sprintf("Task for %s", expr)) 240 | assert.NoError(t, err, "Should accept valid cron expression: %s", expr) 241 | assert.Greater(t, id, int64(0)) 242 | }) 243 | } 244 | } 245 | 246 | // TestCron_CustomDescriptors 測試自定義描述符 247 | func TestCron_CustomDescriptors(t *testing.T) { 248 | c := createTestCron(t) 249 | defer cleanupCron(t, c) 250 | 251 | // 使用能快速測試的描述符 252 | descriptors := []string{ 253 | "@yearly", 254 | "@annually", 255 | "@monthly", 256 | "@weekly", 257 | "@daily", 258 | "@midnight", 259 | "@hourly", 260 | "@every 30s", // 最小間隔 261 | "@every 1m", // 1 分鐘 262 | "@every 5m", // 5 分鐘 263 | } 264 | 265 | for _, desc := range descriptors { 266 | t.Run(fmt.Sprintf("descriptor_%s", desc), func(t *testing.T) { 267 | id, err := c.Add(desc, func() {}, fmt.Sprintf("Task for %s", desc)) 268 | assert.NoError(t, err, "Should accept descriptor: %s", desc) 269 | assert.Greater(t, id, int64(0)) 270 | }) 271 | } 272 | } 273 | 274 | // TestCron_StartStop 測試啟動和停止 275 | func TestCron_StartStop(t *testing.T) { 276 | c := createTestCron(t) 277 | 278 | // Test start 279 | c.Start() 280 | assert.True(t, c.running, "Should be running after start") 281 | 282 | // Test double start (should be safe) 283 | c.Start() 284 | assert.True(t, c.running, "Should still be running after double start") 285 | 286 | // Test stop 287 | ctx := c.Stop() 288 | assert.False(t, c.running, "Should not be running after stop") 289 | 290 | // Wait for stop to complete 291 | select { 292 | case <-ctx.Done(): 293 | // Success 294 | case <-time.After(2 * time.Second): 295 | t.Error("Stop should complete within 2 seconds") 296 | } 297 | } 298 | 299 | // TestCron_StopWithoutStart 測試未啟動時停止 300 | func TestCron_StopWithoutStart(t *testing.T) { 301 | c := createTestCron(t) 302 | 303 | ctx := c.Stop() 304 | 305 | select { 306 | case <-ctx.Done(): 307 | // Should complete immediately 308 | case <-time.After(100 * time.Millisecond): 309 | t.Error("Stop should complete immediately when not started") 310 | } 311 | } 312 | 313 | // TestCron_List 測試任務列表 314 | func TestCron_List(t *testing.T) { 315 | c := createTestCron(t) 316 | defer cleanupCron(t, c) 317 | 318 | // Initially empty 319 | tasks := c.List() 320 | assert.Len(t, tasks, 0, "Should start with empty task list") 321 | 322 | // Add tasks 323 | id1, err := c.Add("@every 30s", func() {}, "Task 1") 324 | require.NoError(t, err) 325 | 326 | id2, err := c.Add("@every 60s", func() {}, "Task 2") 327 | require.NoError(t, err) 328 | 329 | // Check list 330 | tasks = c.List() 331 | assert.Len(t, tasks, 2, "Should have 2 tasks") 332 | 333 | // Verify task IDs 334 | foundTask1 := false 335 | foundTask2 := false 336 | for i := range tasks { 337 | task := &tasks[i] 338 | if task.ID == id1 { 339 | foundTask1 = true 340 | assert.Equal(t, "Task 1", task.description) 341 | } 342 | if task.ID == id2 { 343 | foundTask2 = true 344 | assert.Equal(t, "Task 2", task.description) 345 | } 346 | } 347 | assert.True(t, foundTask1, "Task 1 should be in list") 348 | assert.True(t, foundTask2, "Task 2 should be in list") 349 | } 350 | 351 | // TestCron_Remove 測試任務移除 352 | func TestCron_Remove(t *testing.T) { 353 | c := createTestCron(t) 354 | defer cleanupCron(t, c) 355 | 356 | // Add tasks 357 | id1, err := c.Add("@every 30s", func() {}, "Task 1") 358 | require.NoError(t, err) 359 | 360 | id2, err := c.Add("@every 60s", func() {}, "Task 2") 361 | require.NoError(t, err) 362 | 363 | // Remove one task 364 | c.Remove(id1) 365 | 366 | // Check remaining tasks 367 | tasks := c.List() 368 | assert.Len(t, tasks, 1, "Should have 1 task after removal") 369 | assert.Equal(t, id2, tasks[0].ID, "Wrong task remained after removal") 370 | } 371 | 372 | // TestCron_RemoveAll 測試移除所有任務 373 | func TestCron_RemoveAll(t *testing.T) { 374 | c := createTestCron(t) 375 | defer cleanupCron(t, c) 376 | 377 | // Add multiple tasks 378 | _, err := c.Add("@every 30s", func() {}, "Task 1") 379 | require.NoError(t, err) 380 | 381 | _, err = c.Add("@every 60s", func() {}, "Task 2") 382 | require.NoError(t, err) 383 | 384 | _, err = c.Add("@every 90s", func() {}, "Task 3") 385 | require.NoError(t, err) 386 | 387 | // Verify tasks added 388 | tasks := c.List() 389 | assert.Len(t, tasks, 3, "Should have 3 tasks") 390 | 391 | // Remove all tasks 392 | c.RemoveAll() 393 | 394 | // Verify all tasks removed 395 | tasks = c.List() 396 | assert.Len(t, tasks, 0, "Should have 0 tasks after RemoveAll") 397 | } 398 | 399 | // TestCron_TaskExecution 測試任務執行(優化為更快的測試) 400 | func TestCron_TaskExecution(t *testing.T) { 401 | if testing.Short() { 402 | t.Skip("Skipping execution test in short mode") 403 | } 404 | 405 | c := createAndStartCron(t) 406 | defer cleanupCron(t, c) 407 | 408 | executed := make(chan bool, 1) 409 | 410 | _, err := c.Add("@every 30s", func() { 411 | select { 412 | case executed <- true: 413 | default: 414 | } 415 | }, "Execution test") 416 | require.NoError(t, err) 417 | 418 | // Wait for execution with timeout 419 | select { 420 | case <-executed: 421 | // Test passed 422 | case <-time.After(35 * time.Second): 423 | t.Fatal("Task should have executed within 35 seconds") 424 | } 425 | } 426 | 427 | // TestCron_MultipleTaskExecution 測試多任務執行(優化) 428 | func TestCron_MultipleTaskExecution(t *testing.T) { 429 | if testing.Short() { 430 | t.Skip("Skipping execution test in short mode") 431 | } 432 | 433 | c := createAndStartCron(t) 434 | defer cleanupCron(t, c) 435 | 436 | var wg sync.WaitGroup 437 | wg.Add(2) 438 | 439 | _, err := c.Add("@every 30s", func() { 440 | wg.Done() 441 | }, "Task 1") 442 | require.NoError(t, err) 443 | 444 | _, err = c.Add("@every 30s", func() { 445 | wg.Done() 446 | }, "Task 2") 447 | require.NoError(t, err) 448 | 449 | // Wait for both tasks to execute at least once 450 | done := make(chan struct{}) 451 | go func() { 452 | wg.Wait() 453 | close(done) 454 | }() 455 | 456 | select { 457 | case <-done: 458 | // Both tasks executed 459 | case <-time.After(35 * time.Second): 460 | t.Fatal("Both tasks should have executed within 35 seconds") 461 | } 462 | } 463 | 464 | // TestCron_TaskPanicRecovery 測試 panic 恢復 465 | func TestCron_TaskPanicRecovery(t *testing.T) { 466 | if testing.Short() { 467 | t.Skip("Skipping execution test in short mode") 468 | } 469 | 470 | c := createAndStartCron(t) 471 | defer cleanupCron(t, c) 472 | 473 | normalTaskExecuted := make(chan bool, 1) 474 | 475 | // Add panic task 476 | _, err := c.Add("@every 30s", func() { 477 | panic("test panic") 478 | }, "Panic task") 479 | require.NoError(t, err) 480 | 481 | // Add normal task 482 | _, err = c.Add("@every 30s", func() { 483 | select { 484 | case normalTaskExecuted <- true: 485 | default: 486 | } 487 | }, "Normal task") 488 | require.NoError(t, err) 489 | 490 | // Wait for normal task execution 491 | select { 492 | case <-normalTaskExecuted: 493 | // Test passed 494 | case <-time.After(35 * time.Second): 495 | t.Fatal("Normal task should execute even when other task panics") 496 | } 497 | } 498 | 499 | // TestCron_TaskTimeout 測試任務超時 500 | func TestCron_TaskTimeout(t *testing.T) { 501 | if testing.Short() { 502 | t.Skip("Skipping execution test in short mode") 503 | } 504 | 505 | c := createAndStartCron(t) 506 | defer cleanupCron(t, c) 507 | 508 | timeoutTriggered := make(chan bool, 1) 509 | 510 | _, err := c.Add("@every 30s", func() error { 511 | time.Sleep(2 * time.Second) // Longer than timeout 512 | return nil 513 | }, "Timeout test", 500*time.Millisecond, func() { 514 | select { 515 | case timeoutTriggered <- true: 516 | default: 517 | } 518 | }) 519 | require.NoError(t, err) 520 | 521 | // Wait for timeout 522 | select { 523 | case <-timeoutTriggered: 524 | // Test passed 525 | case <-time.After(35 * time.Second): 526 | t.Fatal("Timeout callback should be triggered") 527 | } 528 | } 529 | 530 | // TestCron_TaskWithoutTimeout 測試無超時任務 531 | func TestCron_TaskWithoutTimeout(t *testing.T) { 532 | if testing.Short() { 533 | t.Skip("Skipping execution test in short mode") 534 | } 535 | 536 | c := createAndStartCron(t) 537 | defer cleanupCron(t, c) 538 | 539 | taskCompleted := make(chan bool, 1) 540 | 541 | _, err := c.Add("@every 30s", func() error { 542 | time.Sleep(200 * time.Millisecond) 543 | select { 544 | case taskCompleted <- true: 545 | default: 546 | } 547 | return nil 548 | }, "No timeout test") 549 | require.NoError(t, err) 550 | 551 | // Wait for completion 552 | select { 553 | case <-taskCompleted: 554 | // Test passed 555 | case <-time.After(35 * time.Second): 556 | t.Fatal("Task should complete normally") 557 | } 558 | } 559 | 560 | // TestCron_Dependencies 測試任務依賴 561 | func TestCron_Dependencies(t *testing.T) { 562 | if testing.Short() { 563 | t.Skip("Skipping dependency test in short mode") 564 | } 565 | 566 | c := createAndStartCron(t) 567 | defer cleanupCron(t, c) 568 | 569 | var execOrder []int64 570 | var mu sync.Mutex 571 | var wg sync.WaitGroup 572 | wg.Add(3) 573 | 574 | addToOrder := func(id int64) { 575 | mu.Lock() 576 | execOrder = append(execOrder, id) 577 | mu.Unlock() 578 | wg.Done() 579 | } 580 | 581 | // Task 1 - no dependencies 582 | task1ID, err := c.Add("@every 30s", func() error { 583 | addToOrder(1) 584 | return nil 585 | }, "task1") 586 | require.NoError(t, err) 587 | 588 | // Task 2 - depends on task1 589 | task2ID, err := c.Add("@every 30s", func() error { 590 | addToOrder(2) 591 | return nil 592 | }, "task2", []Wait{{ID: task1ID}}) 593 | require.NoError(t, err) 594 | 595 | // Task 3 - depends on task2 596 | _, err = c.Add("@every 30s", func() error { 597 | addToOrder(3) 598 | return nil 599 | }, "task3", []Wait{{ID: task2ID}}) 600 | require.NoError(t, err) 601 | 602 | // Wait for execution 603 | done := make(chan struct{}) 604 | go func() { 605 | wg.Wait() 606 | close(done) 607 | }() 608 | 609 | select { 610 | case <-done: 611 | // Check execution order 612 | mu.Lock() 613 | order := make([]int64, len(execOrder)) 614 | copy(order, execOrder) 615 | mu.Unlock() 616 | 617 | assert.Len(t, order, 3, "All tasks should execute") 618 | expectedOrder := []int64{1, 2, 3} 619 | assert.Equal(t, expectedOrder, order, "Tasks should execute in dependency order") 620 | 621 | case <-time.After(2 * time.Minute): 622 | t.Fatal("Dependency test timeout") 623 | } 624 | } 625 | 626 | // 新增:TestCron_SkipFailedDependency 測試 Skip 失敗策略 627 | func TestCron_SkipFailedDependency(t *testing.T) { 628 | if testing.Short() { 629 | t.Skip("Skipping dependency test in short mode") 630 | } 631 | 632 | c := createAndStartCron(t) 633 | defer cleanupCron(t, c) 634 | 635 | var wg sync.WaitGroup 636 | wg.Add(2) // 只等待 2 個任務(失敗任務和依賴任務) 637 | 638 | var mu sync.Mutex 639 | depTaskExecuted := false 640 | 641 | // Failing task 642 | failTaskID, err := c.Add("@every 30s", func() error { 643 | wg.Done() 644 | return errors.New("simulated failure") 645 | }, "fail_task") 646 | require.NoError(t, err) 647 | 648 | // Dependent task with Skip strategy 649 | _, err = c.Add("@every 30s", func() error { 650 | mu.Lock() 651 | depTaskExecuted = true 652 | mu.Unlock() 653 | wg.Done() 654 | return nil 655 | }, "dep_task", []Wait{{ID: failTaskID, State: Skip}}) 656 | require.NoError(t, err) 657 | 658 | // Wait for execution 659 | done := make(chan struct{}) 660 | go func() { 661 | wg.Wait() 662 | close(done) 663 | }() 664 | 665 | select { 666 | case <-done: 667 | mu.Lock() 668 | executed := depTaskExecuted 669 | mu.Unlock() 670 | 671 | assert.True(t, executed, "Dependent task should execute when prerequisite fails with Skip strategy") 672 | 673 | case <-time.After(2 * time.Minute): 674 | t.Fatal("Skip dependency test timeout") 675 | } 676 | } 677 | 678 | // 新增:TestCron_StopFailedDependency 測試 Stop 失敗策略 679 | func TestCron_StopFailedDependency(t *testing.T) { 680 | if testing.Short() { 681 | t.Skip("Skipping dependency test in short mode") 682 | } 683 | 684 | c := createAndStartCron(t) 685 | defer cleanupCron(t, c) 686 | 687 | var wg sync.WaitGroup 688 | wg.Add(1) 689 | 690 | var mu sync.Mutex 691 | depTaskExecuted := false 692 | 693 | // Failing task 694 | failTaskID, err := c.Add("@every 30s", func() error { 695 | wg.Done() 696 | return errors.New("simulated failure") 697 | }, "fail_task") 698 | require.NoError(t, err) 699 | 700 | // Dependent task with Stop strategy (default) 701 | _, err = c.Add("@every 30s", func() error { 702 | mu.Lock() 703 | depTaskExecuted = true 704 | mu.Unlock() 705 | return nil 706 | }, "dep_task", []Wait{{ID: failTaskID, State: Stop}}) 707 | require.NoError(t, err) 708 | 709 | // Wait for failure 710 | done := make(chan struct{}) 711 | go func() { 712 | wg.Wait() 713 | time.Sleep(2 * time.Second) // Wait to ensure dependent task doesn't execute 714 | close(done) 715 | }() 716 | 717 | select { 718 | case <-done: 719 | mu.Lock() 720 | executed := depTaskExecuted 721 | mu.Unlock() 722 | 723 | assert.False(t, executed, "Dependent task should not execute when prerequisite fails with Stop strategy") 724 | 725 | case <-time.After(2 * time.Minute): 726 | t.Fatal("Stop dependency test timeout") 727 | } 728 | } 729 | 730 | // 優化:TestCron_ComplexDependencies 測試複雜依賴關係 731 | func TestCron_ComplexDependencies(t *testing.T) { 732 | if testing.Short() { 733 | t.Skip("Skipping complex dependency test in short mode") 734 | } 735 | 736 | c := createAndStartCron(t) 737 | defer cleanupCron(t, c) 738 | 739 | var execOrder []int64 740 | var mu sync.Mutex 741 | var wg sync.WaitGroup 742 | wg.Add(5) 743 | 744 | addToOrder := func(id int64) { 745 | mu.Lock() 746 | execOrder = append(execOrder, id) 747 | mu.Unlock() 748 | wg.Done() 749 | } 750 | 751 | // Complex dependency graph - 使用最小間隔進行測試 752 | task1ID, _ := c.Add("@every 30s", func() error { addToOrder(1); return nil }, "task1") 753 | task2ID, _ := c.Add("@every 30s", func() error { addToOrder(2); return nil }, "task2") 754 | task3ID, _ := c.Add("@every 30s", func() error { addToOrder(3); return nil }, "task3", []int64{ 755 | task1ID, 756 | task2ID, 757 | }) 758 | // 使用合理的依賴超時時間 759 | delay5s := 5 * time.Second 760 | delay3s := 3 * time.Second 761 | task4ID, _ := c.Add("@every 30s", func() error { addToOrder(4); return nil }, "task4", []Wait{ 762 | {ID: task1ID, Delay: delay5s}, 763 | {ID: task2ID, Delay: delay3s}, 764 | }) 765 | _, _ = c.Add("@every 30s", func() error { addToOrder(5); return nil }, "task5", []Wait{ 766 | {ID: task3ID}, 767 | {ID: task4ID}, 768 | }) 769 | 770 | // Wait for execution 771 | done := make(chan struct{}) 772 | go func() { 773 | wg.Wait() 774 | close(done) 775 | }() 776 | 777 | select { 778 | case <-done: 779 | // Check execution order constraints 780 | mu.Lock() 781 | orderMap := make(map[int64]int) 782 | for i, id := range execOrder { 783 | orderMap[id] = i 784 | } 785 | mu.Unlock() 786 | 787 | // Verify dependency constraints 788 | assert.Less(t, orderMap[1], orderMap[3], "task1 should execute before task3") 789 | assert.Less(t, orderMap[1], orderMap[4], "task1 should execute before task4") 790 | assert.Less(t, orderMap[2], orderMap[4], "task2 should execute before task4") 791 | assert.Less(t, orderMap[3], orderMap[5], "task3 should execute before task5") 792 | assert.Less(t, orderMap[4], orderMap[5], "task4 should execute before task5") 793 | 794 | case <-time.After(45 * time.Second): // 稍微超過一個週期 795 | t.Fatal("Complex dependency test timeout") 796 | } 797 | } 798 | 799 | // Benchmark Tests - 專注於效能,不依賴時間排程 800 | 801 | // BenchmarkCron_Add 添加任務的效能測試 802 | func BenchmarkCron_Add(b *testing.B) { 803 | c, _ := New(testConfig) 804 | defer cleanupCron(&testing.T{}, c) 805 | 806 | b.ResetTimer() 807 | for i := 0; i < b.N; i++ { 808 | _, err := c.Add("@every 30s", func() {}, fmt.Sprintf("task-%d", i)) 809 | if err != nil { 810 | b.Fatal(err) 811 | } 812 | } 813 | } 814 | 815 | // BenchmarkCron_List 列出任務的效能測試 816 | func BenchmarkCron_List(b *testing.B) { 817 | c, _ := New(testConfig) 818 | defer cleanupCron(&testing.T{}, c) 819 | 820 | // Add some tasks 821 | for i := 0; i < 100; i++ { 822 | c.Add("@every 30s", func() {}, fmt.Sprintf("task-%d", i)) 823 | } 824 | 825 | b.ResetTimer() 826 | for i := 0; i < b.N; i++ { 827 | c.List() 828 | } 829 | } 830 | 831 | // BenchmarkCron_AddWithDependencies 依賴任務添加效能測試 832 | func BenchmarkCron_AddWithDependencies(b *testing.B) { 833 | c, _ := New(testConfig) 834 | defer cleanupCron(&testing.T{}, c) 835 | 836 | // 預先創建一個父任務 837 | parentID, _ := c.Add("@every 30s", func() error { return nil }, "parent") 838 | 839 | b.ResetTimer() 840 | for i := 0; i < b.N; i++ { 841 | _, err := c.Add("@every 30s", func() error { return nil }, 842 | fmt.Sprintf("child-%d", i), []Wait{{ID: parentID}}) 843 | if err != nil { 844 | b.Fatal(err) 845 | } 846 | } 847 | } 848 | 849 | // BenchmarkCron_Remove 移除任務的效能測試 850 | func BenchmarkCron_Remove(b *testing.B) { 851 | c, _ := New(testConfig) 852 | defer cleanupCron(&testing.T{}, c) 853 | 854 | // 固定添加 1000 個任務進行測試 855 | const taskCount = 1000 856 | var taskIDs []int64 857 | for i := 0; i < taskCount; i++ { 858 | id, _ := c.Add("@every 30s", func() {}, fmt.Sprintf("task-%d", i)) 859 | taskIDs = append(taskIDs, id) 860 | } 861 | 862 | b.ResetTimer() 863 | // 每次迭代只移除一個任務,循環使用任務 ID 864 | for i := 0; i < b.N; i++ { 865 | // 使用模運算循環使用已有的任務 ID 866 | taskIndex := i % taskCount 867 | c.Remove(taskIDs[taskIndex]) 868 | } 869 | } 870 | 871 | // 新增:BenchmarkCron_AddRemoveCycle 872 | func BenchmarkCron_AddRemoveCycle(b *testing.B) { 873 | c, _ := New(testConfig) 874 | defer cleanupCron(&testing.T{}, c) 875 | 876 | b.ResetTimer() 877 | for i := 0; i < b.N; i++ { 878 | // 每次迭代:添加一個任務,然後立即移除 879 | id, err := c.Add("@every 30s", func() {}, fmt.Sprintf("task-%d", i)) 880 | if err != nil { 881 | b.Fatal(err) 882 | } 883 | c.Remove(id) 884 | } 885 | } 886 | 887 | // 新增:TestCron_ScheduleParsing 測試排程解析(不執行,只測試解析) 888 | func TestCron_ScheduleParsing(t *testing.T) { 889 | c := createTestCron(t) 890 | defer cleanupCron(t, c) 891 | 892 | // 測試各種排程語法的解析,不需要實際執行 893 | scheduleTests := []struct { 894 | name string 895 | spec string 896 | wantErr bool 897 | errorMsg string 898 | }{ 899 | { 900 | name: "yearly schedule", 901 | spec: "@yearly", 902 | wantErr: false, 903 | }, 904 | { 905 | name: "monthly schedule", 906 | spec: "@monthly", 907 | wantErr: false, 908 | }, 909 | { 910 | name: "weekly schedule", 911 | spec: "@weekly", 912 | wantErr: false, 913 | }, 914 | { 915 | name: "daily schedule", 916 | spec: "@daily", 917 | wantErr: false, 918 | }, 919 | { 920 | name: "hourly schedule", 921 | spec: "@hourly", 922 | wantErr: false, 923 | }, 924 | { 925 | name: "every 30s", 926 | spec: "@every 30s", 927 | wantErr: false, 928 | }, 929 | { 930 | name: "every 1m", 931 | spec: "@every 1m", 932 | wantErr: false, 933 | }, 934 | { 935 | name: "every 1h", 936 | spec: "@every 1h", 937 | wantErr: false, 938 | }, 939 | { 940 | name: "invalid every interval", 941 | spec: "@every 1s", 942 | wantErr: true, 943 | errorMsg: "minimum interval is 30s", 944 | }, 945 | { 946 | name: "standard cron 5-field", 947 | spec: "0 0 * * *", 948 | wantErr: false, 949 | }, 950 | { 951 | name: "complex cron expression", 952 | spec: "0 0 1-15 * 1,3,5", 953 | wantErr: false, 954 | }, 955 | } 956 | 957 | for _, tt := range scheduleTests { 958 | t.Run(tt.name, func(t *testing.T) { 959 | _, err := c.Add(tt.spec, func() error { return nil }, "test") 960 | 961 | if tt.wantErr { 962 | assert.Error(t, err) 963 | assert.Contains(t, err.Error(), tt.errorMsg) 964 | } else { 965 | assert.NoError(t, err) 966 | } 967 | }) 968 | } 969 | } 970 | 971 | // 新增:TestCron_FastExecution 快速執行測試(僅測試機制,不等待實際排程) 972 | func TestCron_FastExecution(t *testing.T) { 973 | c := createTestCron(t) 974 | defer cleanupCron(t, c) 975 | 976 | // 模擬任務執行,不依賴實際的時間排程 977 | executed := false 978 | 979 | // 直接測試任務函數 980 | taskFunc := func() error { 981 | executed = true 982 | return nil 983 | } 984 | 985 | // 測試任務函數本身 986 | err := taskFunc() 987 | assert.NoError(t, err) 988 | assert.True(t, executed, "Task function should execute") 989 | 990 | // 測試任務添加 991 | id, err := c.Add("@every 30s", taskFunc, "fast test") 992 | assert.NoError(t, err) 993 | assert.Greater(t, id, int64(0)) 994 | 995 | // 驗證任務已添加到列表 996 | tasks := c.List() 997 | assert.Len(t, tasks, 1) 998 | assert.Equal(t, "fast test", tasks[0].description) 999 | } 1000 | --------------------------------------------------------------------------------