├── .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 | [](https://pkg.go.dev/github.com/pardnchiu/go-scheduler)
7 | [](https://goreportcard.com/report/github.com/pardnchiu/go-scheduler)
8 | [](https://app.codecov.io/github/pardnchiu/go-scheduler)
9 | [](https://github.com/pardnchiu/go-scheduler/releases)
10 | [](LICENSE)
11 | [](https://github.com/avelino/awesome-go)
12 | [](README.md)
13 | [](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 | 主流程
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 | 依賴流程
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 |
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 | [](https://www.star-history.com/#pardnchiu/go-scheduler&Date)
514 |
515 | ## 作者
516 |
517 |
518 |
519 |