add distributed locker for v2 (#614)

* add distributed locker for v2

* fix logger test

* enhance logger test
This commit is contained in:
John Roesler
2023-11-14 09:56:05 -06:00
committed by GitHub
parent 3e2df30371
commit 7fea987137
9 changed files with 191 additions and 56 deletions
+4
View File
@@ -97,6 +97,10 @@ func main() {
- [**Elector**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithDistributedElector): - [**Elector**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithDistributedElector):
An elector can be used to elect a single instance of gocron to run as the primary with the An elector can be used to elect a single instance of gocron to run as the primary with the
other instances checking to see if a new leader needs to be elected. other instances checking to see if a new leader needs to be elected.
- Implementations: [go-co-op electors](https://github.com/go-co-op?q=-elector&type=all&language=&sort=)
- [**Locker**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithDistributedLocker):
A locker can be used to lock each run of a job to a single instance of gocron.
- Implementations: [go-co-op lockers](https://github.com/go-co-op?q=-lock&type=all&language=&sort=)
- **Events**: Job events can trigger actions. - **Events**: Job events can trigger actions.
- [**Listeners**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithEventListeners): - [**Listeners**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#WithEventListeners):
[Event listeners](https://pkg.go.dev/github.com/go-co-op/gocron/v2#EventListener) [Event listeners](https://pkg.go.dev/github.com/go-co-op/gocron/v2#EventListener)
+19 -1
View File
@@ -1,7 +1,9 @@
//go:generate mockgen -source=distributed.go -destination=mocks/distributed.go -package=gocronmocks //go:generate mockgen -source=distributed.go -destination=mocks/distributed.go -package=gocronmocks
package gocron package gocron
import "context" import (
"context"
)
// Elector determines the leader from instances asking to be the leader. Only // Elector determines the leader from instances asking to be the leader. Only
// the leader runs jobs. If the leader goes down, a new leader will be elected. // the leader runs jobs. If the leader goes down, a new leader will be elected.
@@ -10,3 +12,19 @@ type Elector interface {
// making the request and an error if the job should not be scheduled. // making the request and an error if the job should not be scheduled.
IsLeader(context.Context) error IsLeader(context.Context) error
} }
// Locker represents the required interface to lock jobs when running multiple schedulers.
// The lock is held for the duration of the job's run, and it is expected that the
// locker implementation handles time splay between schedulers.
// The lock key passed is the job's name - which, if not set, defaults to the
// go function's name, e.g. "pkg.myJob" for func myJob() {} in pkg
type Locker interface {
// Lock if an error is returned by lock, the job will not be scheduled.
Lock(ctx context.Context, key string) (Lock, error)
}
// Lock represents an obtained lock. The lock is released after the execution of the job
// by the scheduler.
type Lock interface {
Unlock(ctx context.Context) error
}
+1
View File
@@ -30,6 +30,7 @@ var (
ErrWeeklyJobMinutesSeconds = fmt.Errorf("gocron: WeeklyJob: atTimes minutes and seconds must be between 0 and 59 inclusive") ErrWeeklyJobMinutesSeconds = fmt.Errorf("gocron: WeeklyJob: atTimes minutes and seconds must be between 0 and 59 inclusive")
ErrWithClockNil = fmt.Errorf("gocron: WithClock: clock must not be nil") ErrWithClockNil = fmt.Errorf("gocron: WithClock: clock must not be nil")
ErrWithDistributedElectorNil = fmt.Errorf("gocron: WithDistributedElector: elector must not be nil") ErrWithDistributedElectorNil = fmt.Errorf("gocron: WithDistributedElector: elector must not be nil")
ErrWithDistributedLockerNil = fmt.Errorf("gocron: WithDistributedLocker: locker must not be nil")
ErrWithLimitConcurrentJobsZero = fmt.Errorf("gocron: WithLimitConcurrentJobs: limit must be greater than 0") ErrWithLimitConcurrentJobsZero = fmt.Errorf("gocron: WithLimitConcurrentJobs: limit must be greater than 0")
ErrWithLocationNil = fmt.Errorf("gocron: WithLocation: location must not be nil") ErrWithLocationNil = fmt.Errorf("gocron: WithLocation: location must not be nil")
ErrWithLoggerNil = fmt.Errorf("gocron: WithLogger: logger must not be nil") ErrWithLoggerNil = fmt.Errorf("gocron: WithLogger: logger must not be nil")
+25
View File
@@ -426,6 +426,31 @@ func ExampleWithDistributedElector() {
//) //)
} }
func ExampleWithDistributedLocker() {
//var _ Locker = (*myLocker)(nil)
//
//type myLocker struct{}
//
//func (m myLocker) Lock(ctx context.Context, key string) (Lock, error) {
// return &testLock, nil
//}
//
//var _ Lock = (*testLock)(nil)
//
//type testLock struct {
//}
//
//func (t testLock) Unlock(_ context.Context) error {
// return nil
//}
//
//locker := myLocker{}
//
//_, _ = NewScheduler(
// WithDistributedLocker(locker),
//)
}
func ExampleWithEventListeners() { func ExampleWithEventListeners() {
s, _ := NewScheduler() s, _ := NewScheduler()
defer func() { _ = s.Shutdown() }() defer func() { _ = s.Shutdown() }()
+7
View File
@@ -22,6 +22,7 @@ type executor struct {
singletonRunners map[uuid.UUID]singletonRunner singletonRunners map[uuid.UUID]singletonRunner
limitMode *limitModeConfig limitMode *limitModeConfig
elector Elector elector Elector
locker Locker
} }
type singletonRunner struct { type singletonRunner struct {
@@ -340,6 +341,12 @@ func (e *executor) runJob(j internalJob) {
if err := e.elector.IsLeader(j.ctx); err != nil { if err := e.elector.IsLeader(j.ctx); err != nil {
return return
} }
} else if e.locker != nil {
lock, err := e.locker.Lock(j.ctx, j.id.String())
if err != nil {
return
}
defer func() { _ = lock.Unlock(j.ctx) }()
} }
_ = callJobFuncWithParams(j.beforeJobRuns, j.id) _ = callJobFuncWithParams(j.beforeJobRuns, j.id)
+30 -30
View File
@@ -2,7 +2,9 @@
package gocron package gocron
import ( import (
"fmt"
"log" "log"
"strings"
) )
// Logger is the interface that wraps the basic logging methods // Logger is the interface that wraps the basic logging methods
@@ -12,20 +14,20 @@ import (
// or implement your own Logger. The actual level of Log that is logged // or implement your own Logger. The actual level of Log that is logged
// is handled by the implementation. // is handled by the implementation.
type Logger interface { type Logger interface {
Debug(msg string, args ...interface{}) Debug(msg string, args ...any)
Error(msg string, args ...interface{}) Error(msg string, args ...any)
Info(msg string, args ...interface{}) Info(msg string, args ...any)
Warn(msg string, args ...interface{}) Warn(msg string, args ...any)
} }
var _ Logger = (*noOpLogger)(nil) var _ Logger = (*noOpLogger)(nil)
type noOpLogger struct{} type noOpLogger struct{}
func (l noOpLogger) Debug(_ string, _ ...interface{}) {} func (l noOpLogger) Debug(_ string, _ ...any) {}
func (l noOpLogger) Error(_ string, _ ...interface{}) {} func (l noOpLogger) Error(_ string, _ ...any) {}
func (l noOpLogger) Info(_ string, _ ...interface{}) {} func (l noOpLogger) Info(_ string, _ ...any) {}
func (l noOpLogger) Warn(_ string, _ ...interface{}) {} func (l noOpLogger) Warn(_ string, _ ...any) {}
var _ Logger = (*logger)(nil) var _ Logger = (*logger)(nil)
@@ -49,46 +51,44 @@ func NewLogger(level LogLevel) Logger {
return &logger{level: level} return &logger{level: level}
} }
func (l *logger) Debug(msg string, args ...interface{}) { func (l *logger) Debug(msg string, args ...any) {
if l.level < LogLevelDebug { if l.level < LogLevelDebug {
return return
} }
if len(args) == 0 { log.Printf("DEBUG: %s%s\n", msg, logFormatArgs(args...))
log.Printf("DEBUG: %s\n", msg)
return
}
log.Printf("DEBUG: %s, %v\n", msg, args)
} }
func (l *logger) Error(msg string, args ...interface{}) { func (l *logger) Error(msg string, args ...any) {
if l.level < LogLevelError { if l.level < LogLevelError {
return return
} }
if len(args) == 0 { log.Printf("ERROR: %s%s\n", msg, logFormatArgs(args...))
log.Printf("ERROR: %s\n", msg)
return
}
log.Printf("ERROR: %s, %v\n", msg, args)
} }
func (l *logger) Info(msg string, args ...interface{}) { func (l *logger) Info(msg string, args ...any) {
if l.level < LogLevelInfo { if l.level < LogLevelInfo {
return return
} }
if len(args) == 0 { log.Printf("INFO: %s%s\n", msg, logFormatArgs(args...))
log.Printf("INFO: %s\n", msg)
return
}
log.Printf("INFO: %s, %v\n", msg, args)
} }
func (l *logger) Warn(msg string, args ...interface{}) { func (l *logger) Warn(msg string, args ...any) {
if l.level < LogLevelWarn { if l.level < LogLevelWarn {
return return
} }
log.Printf("WARN: %s%s\n", msg, logFormatArgs(args...))
}
func logFormatArgs(args ...any) string {
if len(args) == 0 { if len(args) == 0 {
log.Printf("WARN: %s\n", msg) return ""
return
} }
log.Printf("WARN: %s, %v\n", msg, args) if len(args)%2 != 0 {
return ", " + fmt.Sprint(args...)
}
var pairs []string
for i := 0; i < len(args); i += 2 {
pairs = append(pairs, fmt.Sprintf("%s=%v", args[i], args[i+1]))
}
return ", " + strings.Join(pairs, ", ")
} }
+37 -8
View File
@@ -3,6 +3,7 @@ package gocron
import ( import (
"bytes" "bytes"
"log" "log"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -45,33 +46,61 @@ func TestNewLogger(t *testing.T) {
log.SetOutput(&results) log.SetOutput(&results)
l := NewLogger(tt.level) l := NewLogger(tt.level)
l.Debug("debug", "arg1", "arg2") var noArgs []any
oneArg := []any{"arg1"}
twoArgs := []any{"arg1", "arg2"}
var noArgsStr []string
oneArgStr := []string{"arg1"}
twoArgsStr := []string{"arg1", "arg2"}
for _, args := range []struct {
argsAny []any
argsStr []string
}{
{noArgs, noArgsStr},
{oneArg, oneArgStr},
{twoArgs, twoArgsStr},
} {
l.Debug("debug", args.argsAny...)
if tt.level >= LogLevelDebug { if tt.level >= LogLevelDebug {
assert.Contains(t, results.String(), "DEBUG: debug, [arg1 arg2]\n") r := results.String()
assert.Contains(t, r, "DEBUG: debug")
assert.Contains(t, r, strings.Join(args.argsStr, "="))
} else { } else {
assert.Empty(t, results.String()) assert.Empty(t, results.String())
} }
results.Reset()
l.Info("info", "arg1", "arg2") l.Info("info", args.argsAny...)
if tt.level >= LogLevelInfo { if tt.level >= LogLevelInfo {
assert.Contains(t, results.String(), "INFO: info, [arg1 arg2]\n") r := results.String()
assert.Contains(t, r, "INFO: info")
assert.Contains(t, r, strings.Join(args.argsStr, "="))
} else { } else {
assert.Empty(t, results.String()) assert.Empty(t, results.String())
} }
results.Reset()
l.Warn("warn", "arg1", "arg2") l.Warn("warn", args.argsAny...)
if tt.level >= LogLevelWarn { if tt.level >= LogLevelWarn {
assert.Contains(t, results.String(), "WARN: warn, [arg1 arg2]\n") r := results.String()
assert.Contains(t, r, "WARN: warn")
assert.Contains(t, r, strings.Join(args.argsStr, "="))
} else { } else {
assert.Empty(t, results.String()) assert.Empty(t, results.String())
} }
results.Reset()
l.Error("error", "arg1", "arg2") l.Error("error", args.argsAny...)
if tt.level >= LogLevelError { if tt.level >= LogLevelError {
assert.Contains(t, results.String(), "ERROR: error, [arg1 arg2]\n") r := results.String()
assert.Contains(t, r, "ERROR: error")
assert.Contains(t, r, strings.Join(args.argsStr, "="))
} else { } else {
assert.Empty(t, results.String()) assert.Empty(t, results.String())
} }
results.Reset()
}
}) })
} }
} }
+15
View File
@@ -4,6 +4,7 @@ package gocron
import ( import (
"context" "context"
"reflect" "reflect"
"runtime"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -395,6 +396,7 @@ func (s *scheduler) addOrUpdateJob(id uuid.UUID, definition JobDefinition, taskW
return nil, ErrNewJobTaskNotFunc return nil, ErrNewJobTaskNotFunc
} }
j.name = runtime.FuncForPC(taskFunc.Pointer()).Name()
j.function = tsk.function j.function = tsk.function
j.parameters = tsk.parameters j.parameters = tsk.parameters
@@ -533,6 +535,19 @@ func WithDistributedElector(elector Elector) SchedulerOption {
} }
} }
// WithDistributedLocker sets the locker to be used by multiple
// Scheduler instances to ensure that only one instance of each
// job is run.
func WithDistributedLocker(locker Locker) SchedulerOption {
return func(s *scheduler) error {
if locker == nil {
return ErrWithDistributedLockerNil
}
s.exec.locker = locker
return nil
}
}
// WithGlobalJobOptions sets JobOption's that will be applied to // WithGlobalJobOptions sets JobOption's that will be applied to
// all jobs added to the scheduler. JobOption's set on the job // all jobs added to the scheduler. JobOption's set on the job
// itself will override if the same JobOption is set globally. // itself will override if the same JobOption is set globally.
+38 -2
View File
@@ -811,6 +811,11 @@ func TestScheduler_WithOptionsErrors(t *testing.T) {
WithDistributedElector(nil), WithDistributedElector(nil),
ErrWithDistributedElectorNil, ErrWithDistributedElectorNil,
}, },
{
"WithDistributedLocker nil",
WithDistributedLocker(nil),
ErrWithDistributedLockerNil,
},
{ {
"WithLimitConcurrentJobs limit 0", "WithLimitConcurrentJobs limit 0",
WithLimitConcurrentJobs(0, LimitModeWait), WithLimitConcurrentJobs(0, LimitModeWait),
@@ -1006,15 +1011,46 @@ func (t *testElector) IsLeader(ctx context.Context) error {
return nil return nil
} }
func TestScheduler_WithDistributedElector(t *testing.T) { var _ Locker = (*testLocker)(nil)
type testLocker struct {
mu sync.Mutex
jobLocked bool
}
func (t *testLocker) Lock(_ context.Context, _ string) (Lock, error) {
t.mu.Lock()
defer t.mu.Unlock()
if t.jobLocked {
return nil, fmt.Errorf("job already locked")
}
return &testLock{}, nil
}
var _ Lock = (*testLock)(nil)
type testLock struct{}
func (t testLock) Unlock(_ context.Context) error {
return nil
}
func TestScheduler_WithDistributed(t *testing.T) {
goleak.VerifyNone(t) goleak.VerifyNone(t)
tests := []struct { tests := []struct {
name string name string
count int count int
opt SchedulerOption
}{ }{
{ {
"3 schedulers", "3 schedulers with elector",
3, 3,
WithDistributedElector(&testElector{}),
},
{
"3 schedulers with locker",
3,
WithDistributedLocker(&testLocker{}),
}, },
} }