├── Dockerfile ├── doc ├── config │ ├── README_ZH.md │ └── README.md ├── pic │ ├── job-state.png │ └── delay-queue.png └── arch │ ├── Delay-Queue-HA.jpg │ └── delay-queue-HA.drawio ├── test ├── integration │ ├── benchmark_test.go │ ├── common.go │ └── integration_test.go ├── mock │ ├── pkg │ │ └── lock │ │ │ └── lock.go │ ├── bucket │ │ └── bucket.go │ ├── log │ │ └── log.go │ └── store │ │ └── store.go └── postman │ └── delayqueue.postman_collection.json ├── MAINTAINERS.md ├── pkg ├── encode │ ├── json_test.go │ ├── compress_test.go │ ├── encode.go │ ├── json.go │ ├── encode_benchmark_test.go │ ├── compress.go │ └── encode_test.go ├── redis │ ├── lock.go │ ├── type.go │ ├── kv_test.go │ ├── set.go │ ├── basic.go │ ├── list.go │ ├── kv.go │ ├── hash.go │ ├── zset.go │ └── redis.go ├── lock │ └── lock.go ├── http │ ├── validator.go │ └── response.go └── log │ ├── log.go │ ├── config.go │ ├── field_test.go │ └── field.go ├── vars ├── env.go └── version.go ├── .travis.yml ├── config ├── decode │ ├── decode.go │ ├── json │ │ └── decode.go │ └── yaml │ │ └── decode.go ├── config.yaml.example ├── config.yaml └── config.go ├── CHANGELOG.md ├── .gitignore ├── api ├── handler │ ├── handler.go │ └── action │ │ └── action.go └── api.go ├── store ├── redis │ ├── store.go │ ├── pool.go │ ├── bucket.go │ └── queue.go └── store.go ├── job ├── field.go ├── nameversion_test.go ├── nameversion.go ├── version_test.go ├── version.go ├── job.go └── job_test.go ├── server ├── shutdown.go ├── option.go ├── metric.go ├── server.go └── pprof.go ├── go.mod ├── LICENSE ├── makefile ├── timer ├── timer_test.go └── timer.go ├── queue ├── queue_test.go └── queue.go ├── pool ├── pool.go └── pool_test.go ├── bucket ├── bucket_test.go └── bucket.go ├── README_ZH.md ├── cmd └── delayqueue │ └── main.go ├── dispatch └── dispatch.go └── README.md /Dockerfile: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/config/README_ZH.md: -------------------------------------------------------------------------------- 1 | #delay-queue 配置 -------------------------------------------------------------------------------- /doc/config/README.md: -------------------------------------------------------------------------------- 1 | # delay-queue configuration -------------------------------------------------------------------------------- /test/integration/benchmark_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | * Changsong Li 2 | -------------------------------------------------------------------------------- /doc/pic/job-state.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/changsongl/delay-queue/HEAD/doc/pic/job-state.png -------------------------------------------------------------------------------- /doc/pic/delay-queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/changsongl/delay-queue/HEAD/doc/pic/delay-queue.png -------------------------------------------------------------------------------- /doc/arch/Delay-Queue-HA.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/changsongl/delay-queue/HEAD/doc/arch/Delay-Queue-HA.jpg -------------------------------------------------------------------------------- /pkg/encode/json_test.go: -------------------------------------------------------------------------------- 1 | package encode 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestEncoder(t *testing.T) { 8 | encoder := NewJSON() 9 | runEncodeTest(t, encoder) 10 | } 11 | -------------------------------------------------------------------------------- /pkg/encode/compress_test.go: -------------------------------------------------------------------------------- 1 | package encode 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCompressEncoder(t *testing.T) { 8 | encoder := NewCompress() 9 | runEncodeTest(t, encoder) 10 | } 11 | -------------------------------------------------------------------------------- /vars/env.go: -------------------------------------------------------------------------------- 1 | package vars 2 | 3 | // Env different environment 4 | type Env string 5 | 6 | const ( 7 | // EnvDebug debug env 8 | EnvDebug Env = "debug" 9 | // EnvRelease release env 10 | EnvRelease Env = "release" 11 | ) 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | sudo: false 3 | arch: 4 | - amd64 5 | go: 6 | - "1.15" 7 | - "1.16" 8 | before_install: 9 | - go get -u golang.org/x/lint/golint 10 | script: 11 | - make env 12 | - make build 13 | - make test-unit 14 | 15 | -------------------------------------------------------------------------------- /pkg/encode/encode.go: -------------------------------------------------------------------------------- 1 | package encode 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/job" 5 | ) 6 | 7 | // Encoder encoder interface 8 | type Encoder interface { 9 | Encode(*job.Job) ([]byte, error) 10 | Decode([]byte, *job.Job) error 11 | } 12 | -------------------------------------------------------------------------------- /pkg/redis/lock.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/pkg/lock" 5 | "github.com/go-redsync/redsync/v4" 6 | "time" 7 | ) 8 | 9 | func (r *redis) GetLocker(name string) lock.Locker { 10 | return r.sync.NewMutex(name, redsync.WithExpiry(4*time.Second)) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/lock/lock.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | // LockerFunc locker function given a lock name, 4 | // return a interface with Lock and Unlock method. 5 | type LockerFunc func(name string) Locker 6 | 7 | // Locker locker interface 8 | type Locker interface { 9 | Lock() error 10 | Unlock() (bool, error) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/redis/type.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | // StringMembersToInterface strings slice to interface slice 4 | func StringMembersToInterface(members []string) []interface{} { 5 | is := make([]interface{}, 0, len(members)) 6 | for _, member := range members { 7 | is = append(is, member) 8 | } 9 | 10 | return is 11 | } 12 | -------------------------------------------------------------------------------- /config/decode/decode.go: -------------------------------------------------------------------------------- 1 | package decode 2 | 3 | // Func is common decode function for files 4 | type Func func([]byte, interface{}) error 5 | 6 | // Decoder interface to return a decode function 7 | // for files. different type of file has different 8 | // decoder 9 | type Decoder interface { 10 | DecodeFunc() Func 11 | } 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | 1. Add more unit testings and integration testings. 4 | 2. Add prometheus metrics api. /metrics api. 5 | 3. Pop Api is using BRPOP to pop job from redis queue. Check configuration of delay_queue.pop_job_block_time. 6 | 4. Support Redis Cluster. See more detail in configuration of redis address. 7 | 5. Compress encoding. Similar to protobuf encoding. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | bin 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Dependency directories (remove the comment below to include it) 16 | # vendor/ 17 | 18 | # IDE and OS 19 | .idea 20 | 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /api/handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/server" 5 | "github.com/gin-gonic/gin" 6 | ) 7 | 8 | // Handler interface for register all routers for server 9 | type Handler interface { 10 | Register() server.RouterFunc 11 | } 12 | 13 | // Apply register all handler to engine 14 | func Apply(engine *gin.Engine, handlers ...Handler) { 15 | for _, h := range handlers { 16 | regFunc := h.Register() 17 | regFunc(engine) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /config/decode/json/decode.go: -------------------------------------------------------------------------------- 1 | package json 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/changsongl/delay-queue/config/decode" 6 | ) 7 | 8 | // decoder for json 9 | type decoder struct { 10 | dcFunc decode.Func 11 | } 12 | 13 | // NewDecoder new decoder 14 | func NewDecoder() decode.Decoder { 15 | return &decoder{ 16 | dcFunc: json.Unmarshal, 17 | } 18 | } 19 | 20 | // DecodeFunc decode function 21 | func (d *decoder) DecodeFunc() decode.Func { 22 | return d.dcFunc 23 | } 24 | -------------------------------------------------------------------------------- /vars/version.go: -------------------------------------------------------------------------------- 1 | package vars 2 | 3 | import "fmt" 4 | 5 | // build information 6 | var ( 7 | BuildProgram string 8 | BuildGitPath string 9 | BuildVersion string 10 | BuildTime string 11 | GoVersion string 12 | ) 13 | 14 | // BuildInfo build info 15 | func BuildInfo() string { 16 | return fmt.Sprintf( 17 | "[Build Info] \nProgram : %s \nVersion : %s \nGo Version: %s \nBuild Time: %s \nGithub : %s\n", 18 | BuildProgram, BuildVersion, GoVersion, BuildTime, BuildGitPath) 19 | } 20 | -------------------------------------------------------------------------------- /config/decode/yaml/decode.go: -------------------------------------------------------------------------------- 1 | package yaml 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/config/decode" 5 | "gopkg.in/yaml.v2" 6 | ) 7 | 8 | // decoder for yaml 9 | type decoder struct { 10 | dcFunc decode.Func 11 | } 12 | 13 | // NewDecoder new yaml decoder 14 | func NewDecoder() decode.Decoder { 15 | return &decoder{ 16 | dcFunc: yaml.Unmarshal, 17 | } 18 | } 19 | 20 | // DecodeFunc yaml decode function 21 | func (d *decoder) DecodeFunc() decode.Func { 22 | return d.dcFunc 23 | } 24 | -------------------------------------------------------------------------------- /pkg/redis/kv_test.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/changsongl/delay-queue/config" 7 | "testing" 8 | ) 9 | 10 | func TestGetAndSet(t *testing.T) { 11 | redis := New(config.New().Redis) 12 | err := redis.MSet(context.Background(), map[string]interface{}{"haha": 1111, "hehe": 2222}) 13 | fmt.Println(err) 14 | 15 | Outer: 16 | for i := 0; i < 10; i++ { 17 | fmt.Println(i) 18 | if i == 1 { 19 | fmt.Println("outer") 20 | continue Outer 21 | } 22 | } 23 | fmt.Println("end") 24 | } 25 | -------------------------------------------------------------------------------- /store/redis/store.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/pkg/encode" 5 | "github.com/changsongl/delay-queue/pkg/lock" 6 | "github.com/changsongl/delay-queue/pkg/redis" 7 | "github.com/changsongl/delay-queue/store" 8 | ) 9 | 10 | // storage is store.Store implementation struct, 11 | // it is using redis and encoder to save all data. 12 | type storage struct { 13 | rds redis.Redis 14 | encoder encode.Encoder 15 | } 16 | 17 | // GetLock based on given name, return a common locker. 18 | func (s storage) GetLock(name string) lock.Locker { 19 | return s.rds.GetLocker(name) 20 | } 21 | 22 | // NewStore return a redis storage 23 | func NewStore(r redis.Redis) store.Store { 24 | return &storage{rds: r, encoder: encode.NewCompress()} 25 | } 26 | -------------------------------------------------------------------------------- /pkg/encode/json.go: -------------------------------------------------------------------------------- 1 | package encode 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/job" 5 | jsoniter "github.com/json-iterator/go" 6 | ) 7 | 8 | // implemented Encoder interface 9 | type jsonEncoder struct { 10 | } 11 | 12 | // NewJSON create a json encoder 13 | func NewJSON() Encoder { 14 | return &jsonEncoder{} 15 | } 16 | 17 | // Encode encode function 18 | func (j jsonEncoder) Encode(i *job.Job) ([]byte, error) { 19 | var json = jsoniter.ConfigCompatibleWithStandardLibrary 20 | bytes, err := json.Marshal(i) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | return bytes, nil 26 | } 27 | 28 | // Decode decode function 29 | func (j jsonEncoder) Decode(b []byte, i *job.Job) error { 30 | var json = jsoniter.ConfigCompatibleWithStandardLibrary 31 | 32 | return json.Unmarshal(b, i) 33 | } 34 | -------------------------------------------------------------------------------- /job/field.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Topic job topic 8 | type Topic string 9 | 10 | // ID job ID 11 | type ID string 12 | 13 | // Delay job delay time 14 | type Delay time.Duration 15 | 16 | // TTR job time to run 17 | type TTR time.Duration 18 | 19 | // Body job body 20 | type Body string 21 | 22 | // Empty interface 23 | type Empty interface { 24 | IsEmpty() bool 25 | } 26 | 27 | // IsEmpty topic is empty 28 | func (t Topic) IsEmpty() bool { 29 | return t == "" 30 | } 31 | 32 | // IsEmpty id is empty 33 | func (id ID) IsEmpty() bool { 34 | return id == "" 35 | } 36 | 37 | // IsEmpty body is empty 38 | func (b Body) IsEmpty() bool { 39 | return b == "" 40 | } 41 | 42 | // IsEmpty delay is empty 43 | func (d Delay) IsEmpty() bool { 44 | return d == 0 45 | } 46 | 47 | // IsEmpty ttr is empty 48 | func (t TTR) IsEmpty() bool { 49 | return t == 0 50 | } 51 | -------------------------------------------------------------------------------- /job/nameversion_test.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "testing" 6 | ) 7 | 8 | func TestNameVersionMethods(t *testing.T) { 9 | topic := Topic("name") 10 | id := ID("id") 11 | version := NewVersion() 12 | 13 | nameVer := NewNameVersion(topic, id, version) 14 | nvBytes, err := nameVer.MarshalBinary() 15 | require.NoError(t, err) 16 | 17 | nameVerFromString := NewNameVersionString(string(nvBytes)) 18 | require.Equal(t, nameVerFromString, nameVer, "name version should be same") 19 | 20 | topicParse, idParse, versionParse, err := nameVerFromString.Parse() 21 | require.NoError(t, err) 22 | require.Equal(t, topicParse, topic, "the topic of name version should be same") 23 | require.Equal(t, idParse, id, "the id of name version should be same") 24 | require.Equal(t, versionParse.String(), version.String(), "the version of name version should be same") 25 | 26 | } 27 | -------------------------------------------------------------------------------- /server/shutdown.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | const ( 10 | // shutdown timeout for server, it time comes, and 11 | // server is not quit yet. it will force server to stop. 12 | shutDownTimeout = 6 * time.Second 13 | ) 14 | 15 | // shutdownChan shutdown channel with error 16 | type shutdownChan chan error 17 | 18 | // return a shutdownChan 19 | func newShutdownChan() shutdownChan { 20 | return make(chan error, 1) 21 | } 22 | 23 | // Notify the shutdown channel to stop the server 24 | func (s shutdownChan) Notify(e error) { 25 | s <- e 26 | } 27 | 28 | // Wait for shutdown finished 29 | func (s shutdownChan) Wait() error { 30 | return <-s 31 | } 32 | 33 | // shutdown use shutDownTimeout context to notify the shutdownChan 34 | func shutdown(srv *http.Server, sc shutdownChan) { 35 | ctx, cancel := context.WithTimeout(context.Background(), shutDownTimeout) 36 | defer cancel() 37 | sc.Notify(srv.Shutdown(ctx)) 38 | } 39 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/changsongl/delay-queue 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/agiledragon/gomonkey v2.0.2+incompatible 7 | github.com/changsongl/delay-queue-client v0.1.0-beta.0.20210727025911-85af22aa66a5 8 | github.com/gin-gonic/gin v1.6.3 9 | github.com/go-playground/validator/v10 v10.4.1 // indirect 10 | github.com/go-redis/redis/v8 v8.4.4 11 | github.com/go-redsync/redsync/v4 v4.0.4 12 | github.com/golang/mock v1.5.0 13 | github.com/gomodule/redigo v2.0.0+incompatible // indirect 14 | github.com/json-iterator/go v1.1.11 15 | github.com/leodido/go-urn v1.2.1 // indirect 16 | github.com/prometheus/client_golang v1.9.0 17 | github.com/stretchr/testify v1.7.0 18 | github.com/ugorji/go v1.2.2 // indirect 19 | go.uber.org/zap v1.16.0 20 | golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad // indirect 21 | golang.org/x/sys v0.0.0-20201223074533-0d417f636930 // indirect 22 | google.golang.org/protobuf v1.25.0 // indirect 23 | gopkg.in/yaml.v2 v2.4.0 24 | ) 25 | -------------------------------------------------------------------------------- /pkg/encode/encode_benchmark_test.go: -------------------------------------------------------------------------------- 1 | package encode 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/job" 5 | "github.com/changsongl/delay-queue/pkg/lock" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | // BenchmarkCompressEncode 1000000000 0.000011 ns/op len(32) 11 | func BenchmarkCompressEncode(t *testing.B) { 12 | encoder := NewCompress() 13 | j, err := job.New("jobTopic", "131223", 1, 1, job.Body(longText), func(name string) lock.Locker { 14 | return nil 15 | }) 16 | require.NoError(t, err) 17 | 18 | bytes, _ := encoder.Encode(j) 19 | 20 | _ = encoder.Decode(bytes, j) 21 | } 22 | 23 | // BenchmarkJSONEncode 1000000000 0.000024 ns/op len(92) 24 | func BenchmarkJSONEncode(t *testing.B) { 25 | encoder := NewJSON() 26 | j, err := job.New("jobTopic", "131223", 1, 1, job.Body(longText), func(name string) lock.Locker { 27 | return nil 28 | }) 29 | require.NoError(t, err) 30 | 31 | bytes, _ := encoder.Encode(j) 32 | _ = encoder.Decode(bytes, j) 33 | } 34 | -------------------------------------------------------------------------------- /server/option.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/pkg/log" 5 | "github.com/changsongl/delay-queue/vars" 6 | ) 7 | 8 | // Option is a function to the server 9 | // for setting options 10 | type Option func(s *server) 11 | 12 | // LoggerOption set logger to server, server uses it 13 | // to log result of requests 14 | func LoggerOption(l log.Logger) Option { 15 | return func(s *server) { 16 | s.l = l.WithModule("server") 17 | } 18 | } 19 | 20 | // EnvOption set env for server. 21 | func EnvOption(env vars.Env) Option { 22 | return func(s *server) { 23 | s.env = env 24 | } 25 | } 26 | 27 | // BeforeStartEventOption set before start events. 28 | func BeforeStartEventOption(events ...Event) Option { 29 | return func(s *server) { 30 | s.beforeStart = append(s.beforeStart, events...) 31 | } 32 | } 33 | 34 | // AfterStopEventOption set after stop events. 35 | func AfterStopEventOption(events ...Event) Option { 36 | return func(s *server) { 37 | s.beforeStart = append(s.afterStop, events...) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/job" 5 | "github.com/changsongl/delay-queue/pkg/lock" 6 | "time" 7 | ) 8 | 9 | // Store is a common storage interface, which is for manage jobs pool, 10 | // buckets, and queue. Right now it is based on redis implementation, 11 | // it might be change to other storage like memory or mysql, etc... 12 | type Store interface { 13 | GetLock(name string) lock.Locker 14 | 15 | CreateJob(j *job.Job) error 16 | ReplaceJob(j *job.Job) error 17 | LoadJob(j *job.Job) error 18 | DeleteJob(j *job.Job) (bool, error) 19 | 20 | CreateJobInBucket(bucket string, j *job.Job, isTTR bool) error 21 | GetReadyJobsInBucket(bucket string, num uint) ([]job.NameVersion, error) 22 | CollectInFlightJobNumberBucket(bucket string) (uint64, error) 23 | 24 | PushJobToQueue(queuePrefix, queueName string, j *job.Job) error 25 | PopJobFromQueue(queue string) (job.NameVersion, error) 26 | BPopJobFromQueue(queue string, blockTime time.Duration) (job.NameVersion, error) 27 | CollectInFlightJobNumberQueue(queuePrefix string) (map[string]uint64, error) 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Changsong Li 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 | -------------------------------------------------------------------------------- /job/nameversion.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | // NameVersion name version, combination of topic, id and version 10 | type NameVersion string 11 | 12 | // NewNameVersionString convert string to name version 13 | func NewNameVersionString(str string) NameVersion { 14 | return NameVersion(str) 15 | } 16 | 17 | // NewNameVersion create new name version object 18 | func NewNameVersion(topic Topic, id ID, version Version) NameVersion { 19 | return NameVersion(fmt.Sprintf("%s_%s_%s", topic, id, version)) 20 | } 21 | 22 | // MarshalBinary for json encode 23 | func (nv NameVersion) MarshalBinary() ([]byte, error) { 24 | return []byte(nv), nil 25 | } 26 | 27 | // Parse parse name version to topic, id and version 28 | func (nv NameVersion) Parse() (Topic, ID, Version, error) { 29 | data := strings.Split(string(nv), "_") 30 | if len(data) != 3 { 31 | return "", "", Version{}, errors.New("name version parse failed") 32 | } 33 | topic, id, vStr := Topic(data[0]), ID(data[1]), data[2] 34 | v, err := LoadVersion(vStr) 35 | if err != nil { 36 | return "", "", Version{}, err 37 | } 38 | 39 | return topic, id, v, nil 40 | } 41 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | GOPATH=$(shell go env GOPATH) 2 | 3 | PROGRAM=delayqueue 4 | 5 | BINARY=bin/${PROGRAM} 6 | MAIN_FILE=cmd/delayqueue/main.go 7 | 8 | VERSION=$(shell git describe --tags --always --long --dirty) 9 | GIT_ADDR=$(shell git remote -v | head -n 1 | awk '{print $$2}') 10 | BUILD_TIME=$(shell date +%FT%T%z) 11 | GO_VERSION=$(shell go version | awk '{print $$3}') 12 | 13 | REPO=github.com/changsongl/delay-queue/ 14 | 15 | LDFLAGS=-ldflags "-X ${REPO}vars.BuildProgram=${PROGRAM} -X ${REPO}vars.GoVersion=${GO_VERSION} -X ${REPO}vars.BuildTime=${BUILD_TIME} -X ${REPO}vars.BuildVersion=${VERSION} -X ${REPO}vars.BuildGitPath=${GIT_ADDR}" 16 | 17 | .PHONY: build clean test test-unit test-integration run env 18 | 19 | test-unit: 20 | go test --count=1 `go list ./... | grep -v integration` 21 | 22 | test-integration: 23 | go test --count=1 ./test/integration/... 24 | 25 | test: 26 | go test --count=1 ./... 27 | 28 | build: 29 | golint ./... 30 | go mod download 31 | go fmt ./... 32 | go build -o ${BINARY} ${LDFLAGS} ${MAIN_FILE} 33 | 34 | env: 35 | go env 36 | 37 | run: 38 | ./${BINARY} -config.file ./config/config.yaml 39 | 40 | clean: 41 | @if [ -f ${BINARY} ]; then rm ${BINARY} && rmdir bin; fi 42 | -------------------------------------------------------------------------------- /pkg/redis/set.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func (r *redis) SAdd(ctx context.Context, key string, member ...interface{}) (int64, error) { 8 | return r.client.SAdd(ctx, key, member...).Result() 9 | } 10 | 11 | func (r *redis) SMembers(ctx context.Context, key string) ([]string, error) { 12 | return r.client.SMembers(ctx, key).Result() 13 | } 14 | 15 | func (r *redis) SRem(ctx context.Context, key string, member ...interface{}) (int64, error) { 16 | return r.client.SRem(ctx, key, member...).Result() 17 | } 18 | 19 | func (r *redis) SPop(ctx context.Context, key string) (string, error) { 20 | return r.client.SPop(ctx, key).Result() 21 | } 22 | 23 | func (r *redis) SRandMemberN(ctx context.Context, key string, count int64) ([]string, error) { 24 | return r.client.SRandMemberN(ctx, key, count).Result() 25 | } 26 | 27 | func (r *redis) SRandMember(ctx context.Context, key string) (string, error) { 28 | return r.client.SRandMember(ctx, key).Result() 29 | } 30 | 31 | func (r *redis) SCard(ctx context.Context, key string) (int64, error) { 32 | return r.client.SCard(ctx, key).Result() 33 | } 34 | 35 | func (r *redis) SIsMember(ctx context.Context, key string, member interface{}) (bool, error) { 36 | return r.client.SIsMember(ctx, key, member).Result() 37 | } 38 | -------------------------------------------------------------------------------- /pkg/http/validator.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gin-gonic/gin" 6 | ) 7 | 8 | // Validator validator interface with validate function 9 | type Validator interface { 10 | Validate(c *gin.Context, uri, query, body interface{}) error 11 | } 12 | 13 | type paramError struct { 14 | paramFrom string 15 | paramErr error 16 | } 17 | 18 | func newParamError(paramFrom string, paramErr error) error { 19 | return paramError{ 20 | paramFrom: paramFrom, 21 | paramErr: paramErr, 22 | } 23 | } 24 | 25 | func (p paramError) Error() string { 26 | return fmt.Sprintf("%s invalid: %v", p.paramFrom, p.paramErr) 27 | } 28 | 29 | type validator struct { 30 | } 31 | 32 | // NewValidator create a new validator 33 | func NewValidator() Validator { 34 | return &validator{} 35 | } 36 | 37 | // Validate validate function 38 | func (v *validator) Validate(c *gin.Context, uri, query, body interface{}) error { 39 | if uri != nil { 40 | if err := c.ShouldBindUri(uri); err != nil { 41 | return newParamError("uri", err) 42 | } 43 | } 44 | if query != nil { 45 | if err := c.ShouldBindQuery(query); err != nil { 46 | return newParamError("query", err) 47 | } 48 | } 49 | if body != nil { 50 | if err := c.ShouldBindJSON(body); err != nil { 51 | return newParamError("body", err) 52 | } 53 | } 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /job/version_test.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "strconv" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestNewVersionAndString(t *testing.T) { 11 | before := time.Now().UnixNano() 12 | ver := NewVersion() 13 | after := time.Now().UnixNano() 14 | require.LessOrEqual(t, before, ver.t.UnixNano(), "before should be less than version time") 15 | require.LessOrEqual(t, ver.t.UnixNano(), after, "version time should be less than after") 16 | 17 | verTime, err := strconv.ParseInt(ver.String(), 10, 64) 18 | require.NoError(t, err) 19 | require.LessOrEqual(t, before, verTime, "before should be less than version time") 20 | require.LessOrEqual(t, verTime, after, "version time should be less than after") 21 | } 22 | 23 | func TestNewLoadVersionAndEqual(t *testing.T) { 24 | ver := NewVersion() 25 | verNew, err := LoadVersion(ver.String()) 26 | require.NoError(t, err) 27 | equal := ver.Equal(verNew) 28 | require.Equal(t, equal, true, "version should be same") 29 | } 30 | 31 | func TestVersionMarshalAndUnMarshall(t *testing.T) { 32 | ver := NewVersion() 33 | bytes, err := ver.MarshalJSON() 34 | require.NoError(t, err) 35 | 36 | verSame := Version{} 37 | err = verSame.UnmarshalJSON(bytes) 38 | require.NoError(t, err) 39 | 40 | equal := ver.Equal(verSame) 41 | require.Equal(t, equal, true, "version should be same") 42 | } 43 | -------------------------------------------------------------------------------- /job/version.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | // Version job version, it is a time object. nano timestamp 9 | type Version struct { 10 | t time.Time 11 | } 12 | 13 | // NewVersion create a new version 14 | func NewVersion() Version { 15 | return Version{t: time.Now()} 16 | } 17 | 18 | // String function 19 | func (v Version) String() string { 20 | return strconv.FormatInt(v.t.UnixNano(), 10) 21 | } 22 | 23 | // UInt64 function 24 | func (v Version) UInt64() uint64 { 25 | return uint64(v.t.UnixNano()) 26 | } 27 | 28 | // LoadVersion load version from a string 29 | func LoadVersion(vs string) (Version, error) { 30 | version := Version{} 31 | vi, err := strconv.ParseInt(vs, 10, 64) 32 | if err != nil { 33 | return version, err 34 | } 35 | 36 | return Version{t: time.Unix(vi/1e9, vi%1e9)}, nil 37 | } 38 | 39 | // Equal check version v equals to version v2 40 | func (v Version) Equal(v2 Version) bool { 41 | return v.t.UnixNano() == v2.t.UnixNano() 42 | } 43 | 44 | // MarshalJSON json marshall 45 | func (v Version) MarshalJSON() ([]byte, error) { 46 | return []byte(v.String()), nil 47 | } 48 | 49 | // UnmarshalJSON json unmarshall 50 | func (v *Version) UnmarshalJSON(b []byte) error { 51 | vi, err := strconv.ParseInt(string(b), 10, 64) 52 | if err != nil { 53 | return err 54 | } 55 | v.t = time.Unix(vi/1e9, vi%1e9) 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /pkg/redis/basic.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | gredis "github.com/go-redis/redis/v8" 6 | "time" 7 | ) 8 | 9 | // IsError check if redis error, exclude redis nil 10 | func IsError(err error) bool { 11 | return err != nil && err != gredis.Nil 12 | } 13 | 14 | // IsNil check if it is redis nil 15 | func IsNil(err error) bool { 16 | return err == gredis.Nil 17 | } 18 | 19 | // Del a key 20 | func (r *redis) Del(ctx context.Context, key string) (bool, error) { 21 | result, err := r.client.Del(ctx, key).Result() 22 | return result == 1, err 23 | } 24 | 25 | // Exists check a key is exists 26 | func (r *redis) Exists(ctx context.Context, key string) (bool, error) { 27 | result, err := r.client.Exists(ctx, key).Result() 28 | return result == 1, err 29 | } 30 | 31 | // Expire a key 32 | func (r *redis) Expire(ctx context.Context, key string, expiration time.Duration) (bool, error) { 33 | return r.client.Expire(ctx, key, expiration).Result() 34 | } 35 | 36 | // ExpireAt set a key expire time 37 | func (r *redis) ExpireAt(ctx context.Context, key string, tm time.Time) (bool, error) { 38 | return r.client.ExpireAt(ctx, key, tm).Result() 39 | } 40 | 41 | // TTL get time to live 42 | func (r *redis) TTL(ctx context.Context, key string) (time.Duration, error) { 43 | return r.client.TTL(ctx, key).Result() 44 | } 45 | 46 | // FlushDB flush all data 47 | func (r *redis) FlushDB(ctx context.Context) error { 48 | return r.client.FlushDB(ctx).Err() 49 | } 50 | -------------------------------------------------------------------------------- /test/integration/common.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/changsongl/delay-queue/config" 7 | "github.com/changsongl/delay-queue/pkg/redis" 8 | ) 9 | 10 | const ( 11 | // RedisAddr redis default address 12 | RedisAddr = "127.0.0.1:6379" 13 | // DelayQueueAddr delay queue default address 14 | DelayQueueAddr = "http://127.0.0.1:8000" 15 | ) 16 | 17 | var redisInstance redis.Redis 18 | 19 | func init() { 20 | if err := CleanTestingStates(); err != nil { 21 | panic(fmt.Sprintf("Integration test failed: init(): %s", err.Error())) 22 | } 23 | } 24 | 25 | // CleanTestingStates clean all states from the previous testing 26 | func CleanTestingStates() error { 27 | return GetRedis().FlushDB(context.Background()) 28 | } 29 | 30 | // GetRedis get redis 31 | func GetRedis() redis.Redis { 32 | if redisInstance == nil { 33 | redisConf := config.New().Redis 34 | redisConf.Address = RedisAddr 35 | redisInstance = redis.New(config.New().Redis) 36 | } 37 | 38 | return redisInstance 39 | } 40 | 41 | // AddJobRecord add job to set 42 | func AddJobRecord(key, job string) error { 43 | _, err := GetRedis().SAdd(context.Background(), key, job) 44 | return err 45 | } 46 | 47 | // DeleteJobRecord delete job record 48 | func DeleteJobRecord(key, job string) error { 49 | _, err := GetRedis().SRem(context.Background(), key, job) 50 | return err 51 | } 52 | 53 | // RecordNumbers get record num 54 | func RecordNumbers(key string) (int64, error) { 55 | return GetRedis().SCard(context.Background(), key) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/http/response.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/job" 5 | "github.com/gin-gonic/gin" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | const ( 11 | // SuccessMessage ok response 12 | SuccessMessage = "ok" 13 | ) 14 | 15 | // Response response struct 16 | type Response struct { 17 | } 18 | 19 | func responseOk(ctx *gin.Context, m map[string]interface{}) { 20 | ctx.JSON(http.StatusOK, m) 21 | } 22 | 23 | // Pong response pong 24 | func (r *Response) Pong(ctx *gin.Context) { 25 | responseOk(ctx, map[string]interface{}{"success": true, "message": "pong"}) 26 | } 27 | 28 | // Ok response ok 29 | func (r *Response) Ok(ctx *gin.Context) { 30 | responseOk(ctx, map[string]interface{}{ 31 | "success": true, 32 | "message": SuccessMessage, 33 | }) 34 | } 35 | 36 | // OkWithJob response ok and with a job object 37 | func (r *Response) OkWithJob(ctx *gin.Context, j *job.Job) { 38 | var jobMap map[string]interface{} 39 | if j != nil { 40 | jobMap = map[string]interface{}{ 41 | "topic": j.Topic, 42 | "id": j.ID, 43 | "body": j.Body, 44 | "ttr": time.Duration(j.TTR) / time.Second, 45 | "delay": time.Duration(j.Delay) / time.Second, 46 | } 47 | } 48 | 49 | responseOk(ctx, map[string]interface{}{ 50 | "success": true, 51 | "message": SuccessMessage, 52 | "data": jobMap, 53 | }) 54 | } 55 | 56 | // Error response 57 | func (r *Response) Error(ctx *gin.Context, err error) { 58 | responseOk(ctx, map[string]interface{}{ 59 | "success": false, 60 | "message": err.Error(), 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | "strings" 6 | ) 7 | 8 | // Logger logger interface 9 | type Logger interface { 10 | WithModule(module string) Logger 11 | 12 | Debug(msg string, fields ...Field) 13 | Info(msg string, fields ...Field) 14 | Error(msg string, fields ...Field) 15 | Fatal(msg string, fields ...Field) 16 | Write(p []byte) (n int, err error) 17 | } 18 | 19 | // logger 20 | type logger struct { 21 | z *zap.Logger 22 | } 23 | 24 | // New a logger 25 | func New(opts ...Option) (Logger, error) { 26 | c := NewConfig() 27 | opts = append(opts, CallSkipOption(1)) 28 | 29 | for _, opt := range opts { 30 | opt.apply(c) 31 | } 32 | 33 | l, err := c.conf.Build(c.getZapOptions()...) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return &logger{z: l}, nil 39 | } 40 | 41 | func (l *logger) WithModule(module string) Logger { 42 | lClone := *l 43 | lClone.z = lClone.z.Named(module) 44 | return &lClone 45 | } 46 | 47 | func (l *logger) Debug(s string, fs ...Field) { 48 | l.z.Debug(s, getZapFields(fs...)...) 49 | } 50 | 51 | func (l *logger) Info(s string, fs ...Field) { 52 | l.z.Info(s, getZapFields(fs...)...) 53 | } 54 | 55 | func (l *logger) Warn(s string, fs ...Field) { 56 | l.z.Warn(s, getZapFields(fs...)...) 57 | } 58 | 59 | func (l *logger) Error(s string, fs ...Field) { 60 | l.z.Error(s, getZapFields(fs...)...) 61 | } 62 | 63 | func (l *logger) Fatal(s string, fs ...Field) { 64 | l.z.Fatal(s, getZapFields(fs...)...) 65 | } 66 | 67 | func (l *logger) Write(b []byte) (n int, err error) { 68 | l.Info(strings.TrimRight(string(b), "\n")) 69 | return len(b), nil 70 | } 71 | -------------------------------------------------------------------------------- /pkg/redis/list.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | func (r *redis) LPush(ctx context.Context, key string, values ...interface{}) (int64, error) { 9 | return r.client.LPush(ctx, key, values...).Result() 10 | } 11 | 12 | func (r *redis) RPush(ctx context.Context, key string, values ...interface{}) (int64, error) { 13 | return r.client.RPush(ctx, key, values...).Result() 14 | } 15 | 16 | func (r *redis) RPop(ctx context.Context, key string) (string, error) { 17 | return r.client.RPop(ctx, key).Result() 18 | } 19 | 20 | func (r *redis) BRPop(ctx context.Context, key string, blockTime time.Duration) ([]string, error) { 21 | return r.client.BRPop(ctx, blockTime, key).Result() 22 | } 23 | 24 | func (r *redis) LPop(ctx context.Context, key string) (string, error) { 25 | return r.client.LPop(ctx, key).Result() 26 | } 27 | 28 | func (r *redis) LRange(ctx context.Context, key string, start, stop int64) ([]string, error) { 29 | return r.client.LRange(ctx, key, start, stop).Result() 30 | } 31 | 32 | func (r *redis) LLen(ctx context.Context, key string) (int64, error) { 33 | return r.client.LLen(ctx, key).Result() 34 | } 35 | 36 | func (r *redis) LRem(ctx context.Context, key string, count int64, value interface{}) (int64, error) { 37 | return r.client.LRem(ctx, key, count, value).Result() 38 | } 39 | 40 | func (r *redis) LIndex(ctx context.Context, key string, idx int64) (string, error) { 41 | return r.client.LIndex(ctx, key, idx).Result() 42 | } 43 | 44 | func (r *redis) LTrim(ctx context.Context, key string, start, stop int64) (string, error) { 45 | return r.client.LTrim(ctx, key, start, stop).Result() 46 | } 47 | -------------------------------------------------------------------------------- /store/redis/pool.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/changsongl/delay-queue/job" 7 | "github.com/changsongl/delay-queue/pkg/redis" 8 | ) 9 | 10 | // LoadJob information 11 | func (s *storage) LoadJob(j *job.Job) error { 12 | jobData, err := s.rds.Get(context.Background(), j.GetName()) 13 | if redis.IsError(err) { 14 | return err 15 | } else if redis.IsNil(err) { 16 | return errors.New("job is not exists") 17 | } 18 | 19 | return s.encoder.Decode([]byte(jobData), j) 20 | } 21 | 22 | // CreateJob information, only if the job is not exists. 23 | func (s *storage) CreateJob(j *job.Job) error { 24 | str, err := s.encoder.Encode(j) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | result, err := s.rds.SetNx(context.Background(), j.GetName(), str) 30 | if err != nil { 31 | return err 32 | } else if !result { 33 | return errors.New("job is exists") 34 | } 35 | 36 | return nil 37 | } 38 | 39 | // ReplaceJob Replace job information, only if the job is exists. 40 | func (s *storage) ReplaceJob(j *job.Job) error { 41 | str, err := s.encoder.Encode(j) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | exists, err := s.rds.Exists(context.Background(), j.GetName()) 47 | if err != nil { 48 | return err 49 | } else if !exists { 50 | return errors.New("job is not exists") 51 | } 52 | 53 | err = s.rds.Set(context.Background(), j.GetName(), str) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | return nil 59 | } 60 | 61 | // DeleteJob delete job in redis 62 | func (s *storage) DeleteJob(j *job.Job) (bool, error) { 63 | return s.rds.Del(context.Background(), j.GetName()) 64 | } 65 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/api/handler" 5 | "github.com/changsongl/delay-queue/api/handler/action" 6 | "github.com/changsongl/delay-queue/dispatch" 7 | "github.com/changsongl/delay-queue/pkg/http" 8 | "github.com/changsongl/delay-queue/pkg/log" 9 | "github.com/changsongl/delay-queue/server" 10 | "github.com/gin-gonic/gin" 11 | "sync" 12 | ) 13 | 14 | // API interface, return a router func to register 15 | // all handlers for gin engine 16 | type API interface { 17 | RouterFunc() server.RouterFunc 18 | } 19 | 20 | // api struct implemented API 21 | type api struct { 22 | l log.Logger 23 | httpHandler handler.Handler 24 | rsp http.Response 25 | sync.Once 26 | } 27 | 28 | // NewAPI with logger object and dispatch 29 | func NewAPI(l log.Logger, dispatch dispatch.Dispatch) API { 30 | logger := l.WithModule("api") 31 | responseHelper := http.Response{} 32 | httpHandler := action.NewHandler( 33 | responseHelper, logger, http.NewValidator(), dispatch) 34 | 35 | return &api{ 36 | l: logger, 37 | httpHandler: httpHandler, 38 | rsp: responseHelper, 39 | } 40 | } 41 | 42 | // RouterFunc return a server.RouterFunc which register 43 | // ping and all delay queue handler actions. 44 | func (a *api) RouterFunc() server.RouterFunc { 45 | rf := func(engine *gin.Engine) {} 46 | 47 | a.Do(func() { 48 | pingFunc := func(ctx *gin.Context) { 49 | a.rsp.Pong(ctx) 50 | } 51 | 52 | rf = func(engine *gin.Engine) { 53 | engine.GET("ping", pingFunc) 54 | 55 | handler.Apply( 56 | engine, 57 | a.httpHandler, 58 | ) 59 | } 60 | }) 61 | 62 | return rf 63 | } 64 | -------------------------------------------------------------------------------- /timer/timer_test.go: -------------------------------------------------------------------------------- 1 | package timer 2 | 3 | import ( 4 | "errors" 5 | log "github.com/changsongl/delay-queue/test/mock/log" 6 | "github.com/golang/mock/gomock" 7 | "github.com/stretchr/testify/require" 8 | "sync/atomic" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestTimer(t *testing.T) { 14 | ctrl := gomock.NewController(t) 15 | defer ctrl.Finish() 16 | 17 | logger := log.NewMockLogger(ctrl) 18 | logger.EXPECT().WithModule(gomock.Any()).MaxTimes(3).Return(logger) 19 | logger.EXPECT().Error(gomock.Any(), gomock.Any()).MaxTimes(3) 20 | 21 | delay, interval := time.Second, 2*time.Second 22 | var noDelayCount, delayCount int64 23 | testCases := []struct { 24 | sleep time.Duration 25 | atLeast int64 26 | task TaskFunc 27 | count *int64 28 | }{ 29 | {sleep: 5 * interval, atLeast: 4, count: &noDelayCount, task: func() (hasMore bool, err error) { 30 | atomic.AddInt64(&noDelayCount, 1) 31 | return false, nil 32 | }}, 33 | {sleep: 5 * interval, atLeast: 9, count: &delayCount, task: func() (hasMore bool, err error) { 34 | atomic.AddInt64(&delayCount, 1) 35 | return true, nil 36 | }}, 37 | {sleep: 2 * interval, atLeast: 1, count: &delayCount, task: func() (hasMore bool, err error) { 38 | atomic.AddInt64(&delayCount, 1) 39 | return false, errors.New("error") 40 | }}, 41 | } 42 | 43 | for _, test := range testCases { 44 | t.Log("start TestTimer case") 45 | tm := New(logger, interval, delay) 46 | tm.AddTask(test.task) 47 | t.Log("timer run") 48 | go tm.Run() 49 | 50 | time.Sleep(test.sleep) 51 | 52 | checkNum := atomic.LoadInt64(test.count) 53 | require.GreaterOrEqual(t, checkNum, test.atLeast) 54 | 55 | t.Log("timer close") 56 | tm.Close() 57 | t.Log("end TestTimer case") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /store/redis/bucket.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "github.com/changsongl/delay-queue/job" 6 | "github.com/changsongl/delay-queue/pkg/redis" 7 | "time" 8 | ) 9 | 10 | // CreateJobInBucket create job which will be ready after delay time 11 | func (s *storage) CreateJobInBucket(bucketName string, j *job.Job, isTTR bool) error { 12 | var delayTime float64 13 | if isTTR { 14 | delayTime = float64(j.GetTTRTimeFromNow().Unix()) 15 | } else { 16 | delayTime = float64(j.GetDelayTimeFromNow().Unix()) 17 | } 18 | 19 | _, err := s.rds.ZAdd(context.Background(), bucketName, redis.Z{ 20 | Score: delayTime, 21 | Member: j.GetNameWithVersion(), 22 | }) 23 | 24 | return err 25 | } 26 | 27 | // GetReadyJobsInBucket get job which is ready to be pushed to queue 28 | func (s *storage) GetReadyJobsInBucket(bucket string, num uint) ([]job.NameVersion, error) { 29 | nameStrings, err := s.rds.ZRangeByScoreByOffset( 30 | context.Background(), 31 | bucket, 32 | 0, 33 | time.Now().Unix(), 34 | 0, 35 | int64(num), 36 | ) 37 | 38 | nvs := make([]job.NameVersion, 0, len(nameStrings)) 39 | 40 | if err != nil { 41 | return nil, err 42 | } else if len(nameStrings) == 0 { 43 | return nvs, nil 44 | } 45 | 46 | _, err = s.rds.ZRem(context.Background(), bucket, redis.StringMembersToInterface(nameStrings)...) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | for _, nameString := range nameStrings { 52 | nvs = append(nvs, job.NewNameVersionString(nameString)) 53 | } 54 | return nvs, nil 55 | } 56 | 57 | // CollectInFlightJobNumber collect the number of inflight jobs in bucket 58 | func (s *storage) CollectInFlightJobNumberBucket(bucket string) (uint64, error) { 59 | num, err := s.rds.ZCard(context.Background(), bucket) 60 | return uint64(num), err 61 | } 62 | -------------------------------------------------------------------------------- /test/mock/pkg/lock/lock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: ../pkg/lock/lock.go 3 | 4 | // Package mock_lock is a generated GoMock package. 5 | package mock_lock 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "github.com/golang/mock/gomock" 11 | ) 12 | 13 | // MockLocker is a mock of Locker interface. 14 | type MockLocker struct { 15 | ctrl *gomock.Controller 16 | recorder *MockLockerMockRecorder 17 | } 18 | 19 | // MockLockerMockRecorder is the mock recorder for MockLocker. 20 | type MockLockerMockRecorder struct { 21 | mock *MockLocker 22 | } 23 | 24 | // NewMockLocker creates a new mock instance. 25 | func NewMockLocker(ctrl *gomock.Controller) *MockLocker { 26 | mock := &MockLocker{ctrl: ctrl} 27 | mock.recorder = &MockLockerMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use. 32 | func (m *MockLocker) EXPECT() *MockLockerMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Lock mocks base method. 37 | func (m *MockLocker) Lock() error { 38 | m.ctrl.T.Helper() 39 | ret := m.ctrl.Call(m, "Lock") 40 | ret0, _ := ret[0].(error) 41 | return ret0 42 | } 43 | 44 | // Lock indicates an expected call of Lock. 45 | func (mr *MockLockerMockRecorder) Lock() *gomock.Call { 46 | mr.mock.ctrl.T.Helper() 47 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Lock", reflect.TypeOf((*MockLocker)(nil).Lock)) 48 | } 49 | 50 | // Unlock mocks base method. 51 | func (m *MockLocker) Unlock() (bool, error) { 52 | m.ctrl.T.Helper() 53 | ret := m.ctrl.Call(m, "Unlock") 54 | ret0, _ := ret[0].(bool) 55 | ret1, _ := ret[1].(error) 56 | return ret0, ret1 57 | } 58 | 59 | // Unlock indicates an expected call of Unlock. 60 | func (mr *MockLockerMockRecorder) Unlock() *gomock.Call { 61 | mr.mock.ctrl.T.Helper() 62 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unlock", reflect.TypeOf((*MockLocker)(nil).Unlock)) 63 | } 64 | -------------------------------------------------------------------------------- /store/redis/queue.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "github.com/changsongl/delay-queue/job" 6 | "github.com/changsongl/delay-queue/pkg/redis" 7 | "time" 8 | ) 9 | 10 | // PushJobToQueue push the job to the given redis queue 11 | func (s *storage) PushJobToQueue(queuePrefix, queueName string, j *job.Job) error { 12 | ctx := context.Background() 13 | _, _ = s.rds.SAdd(ctx, queuePrefix, queueName) 14 | _, err := s.rds.LPush(ctx, queueName, j.GetNameWithVersion()) 15 | return err 16 | } 17 | 18 | // PopJobFromQueue pop the job from redis queue 19 | func (s *storage) PopJobFromQueue(queue string) (job.NameVersion, error) { 20 | nv, err := s.rds.RPop(context.Background(), queue) 21 | if redis.IsError(err) { 22 | return "", err 23 | } else if redis.IsNil(err) { 24 | return "", nil 25 | } 26 | return job.NameVersion(nv), nil 27 | } 28 | 29 | // BPopJobFromQueue pop the job from redis queue with block time 30 | func (s *storage) BPopJobFromQueue(queue string, blockTime time.Duration) (job.NameVersion, error) { 31 | queueElement, err := s.rds.BRPop(context.Background(), queue, blockTime) 32 | if redis.IsError(err) { 33 | return "", err 34 | } else if redis.IsNil(err) { 35 | return "", nil 36 | } else if len(queueElement) != 2 { 37 | return "", nil 38 | } 39 | 40 | return job.NameVersion(queueElement[1]), nil 41 | } 42 | 43 | // CollectInFlightJobNumberQueue collect in flight job numbers in all queues 44 | func (s *storage) CollectInFlightJobNumberQueue(queuePrefix string) (map[string]uint64, error) { 45 | ctx := context.Background() 46 | members, err := s.rds.SMembers(ctx, queuePrefix) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | queueMapNum := make(map[string]uint64, len(members)) 52 | for _, member := range members { 53 | num, err := s.rds.LLen(ctx, member) 54 | if err != nil { 55 | return nil, err 56 | } 57 | queueMapNum[member] = uint64(num) 58 | } 59 | 60 | return queueMapNum, nil 61 | } 62 | -------------------------------------------------------------------------------- /server/metric.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/prometheus/client_golang/prometheus" 6 | "github.com/prometheus/client_golang/prometheus/promhttp" 7 | "net/http" 8 | "strconv" 9 | "time" 10 | ) 11 | 12 | // server prometheus metrics 13 | var ( 14 | requestDuration = prometheus.NewHistogramVec( 15 | prometheus.HistogramOpts{ 16 | Name: "delay_queue_http_request_duration_seconds", 17 | Help: "Histogram of latencies for HTTP requests.", 18 | Buckets: []float64{.05, 0.1, .25, .5, .75, 1, 2, 5, 20, 60}, 19 | }, 20 | []string{"handler", "method"}, 21 | ) 22 | 23 | requestStatus = prometheus.NewCounterVec( 24 | prometheus.CounterOpts{ 25 | Name: "delay_queue_http_request_status", 26 | Help: "Counter of requests' HTTP code.", 27 | }, 28 | []string{"handler", "method", "code"}, 29 | ) 30 | ) 31 | 32 | // init all metrics to prometheus default register 33 | func init() { 34 | prometheus.MustRegister(requestDuration) 35 | prometheus.MustRegister(requestStatus) 36 | } 37 | 38 | // setServerMetricHandlerAndMiddleware return a function to set 39 | // metrics api and a middleware to save http request statistics. 40 | func setServerMetricHandlerAndMiddleware() func(r *gin.Engine) { 41 | return func(r *gin.Engine) { 42 | r.GET("/metrics", func(c *gin.Context) { 43 | promhttp.Handler().ServeHTTP(c.Writer, c.Request) 44 | }) 45 | r.Use(saveHTTPServerStat) 46 | } 47 | } 48 | 49 | // saveHTTPServerStat save http server request stats to metrics. 50 | func saveHTTPServerStat(c *gin.Context) { 51 | startTime := time.Now() 52 | url, method := c.Request.URL.Path, c.Request.Method 53 | c.Next() 54 | 55 | requestDuration.With(map[string]string{ 56 | "handler": url, 57 | "method": method, 58 | }).Observe(time.Since(startTime).Seconds()) 59 | 60 | status := c.Writer.Status() 61 | if status != http.StatusNotFound { 62 | requestStatus.With(map[string]string{ 63 | "handler": url, 64 | "method": method, 65 | "code": strconv.Itoa(c.Writer.Status()), 66 | }).Inc() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/log/config.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | "go.uber.org/zap/zapcore" 6 | ) 7 | 8 | // Level log level 9 | type Level int8 10 | 11 | const ( 12 | // LevelDebug debug 13 | LevelDebug Level = iota 14 | // LevelInfo info 15 | LevelInfo 16 | // LevelWarn warn 17 | LevelWarn 18 | // LevelError error 19 | LevelError 20 | // LevelFatal fatal 21 | LevelFatal 22 | ) 23 | 24 | // levelMap level map zap level 25 | var levelMap = map[Level]zapcore.Level{ 26 | LevelDebug: zap.DebugLevel, 27 | LevelInfo: zapcore.InfoLevel, 28 | LevelWarn: zapcore.WarnLevel, 29 | LevelError: zapcore.ErrorLevel, 30 | LevelFatal: zapcore.FatalLevel, 31 | } 32 | 33 | // Config object 34 | type Config struct { 35 | conf zap.Config 36 | zapOption []zap.Option 37 | } 38 | 39 | // NewConfig create new log configuration 40 | func NewConfig() *Config { 41 | c := &Config{conf: zap.NewProductionConfig()} 42 | c.conf.Encoding = "console" 43 | c.conf.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder 44 | 45 | return c 46 | } 47 | 48 | // Option option interface 49 | type Option interface { 50 | apply(*Config) 51 | } 52 | 53 | // option function 54 | type optionFunc func(*Config) 55 | 56 | // apply function 57 | func (f optionFunc) apply(c *Config) { 58 | f(c) 59 | } 60 | 61 | // IsDevelopOption Set delopement option 62 | func IsDevelopOption() Option { 63 | return optionFunc(func(c *Config) { 64 | c.conf.Development = true 65 | }) 66 | } 67 | 68 | // LevelOption set log level 69 | func LevelOption(level Level) Option { 70 | l := zap.DebugLevel 71 | zapL, exists := levelMap[level] 72 | if exists { 73 | l = zapL 74 | } 75 | 76 | return optionFunc(func(c *Config) { 77 | c.conf.Level = zap.NewAtomicLevelAt(l) 78 | }) 79 | } 80 | 81 | // CallSkipOption skip caller 82 | func CallSkipOption(stack int) Option { 83 | return optionFunc(func(c *Config) { 84 | c.zapOption = append(c.zapOption, zap.AddCallerSkip(stack)) 85 | }) 86 | } 87 | 88 | // get zap options 89 | func (c *Config) getZapOptions() []zap.Option { 90 | return c.zapOption 91 | } 92 | -------------------------------------------------------------------------------- /config/config.yaml.example: -------------------------------------------------------------------------------- 1 | # delay queue config 2 | delay_queue: 3 | bind_address: ":8000" # listen address 4 | bucket_name: "dqbucket" # bucket redis key name 5 | bucket_size: 8 # the number of delay queue bucket. 6 | # increase number could get better concurrency. 7 | queue_name: "dqqueue" # queue redis key name 8 | bucket_max_fetch_num: 250 # max fetch number of jobs in the bucket 9 | timer_fetch_interval: 2000 # fetching job interval(ms), decrease interval may get better throughout. 10 | timer_fetch_delay: 0 # fetch delay(ms), if there are still job in the bucket after the fetch, 11 | # it will delay timer_fetch_delay ms for next fetch. Default is not wait. 12 | 13 | # redis config 14 | redis: 15 | mode: "" # redis set up. (EX: "", "cluster") default is empty. 16 | # set "cluster", if you are using redis cluster. 17 | network: "tcp" # redis network: tcp, unix. This is only for Single redis, not cluster. 18 | address: "127.0.0.1:6379" # host:port address for normal redis address. (EX: 127.0.0.1:6379). 19 | # redis cluster mode(ip1:port1,ip2:port2,ip3:port3) 20 | dial_timeout: 5000 # dial timeout for establishing new connections. 21 | # default is 5 seconds. (ms) 22 | read_timeout: 2000 # timeout for socket reads. If reached, commands will failwith a timeout instead 23 | # of blocking. Use value -1 for no timeout and 0 for default.(ms) 24 | write_timeout: 2000 # timeout for socket writes. If reached, commands will fail 25 | # with a timeout instead of blocking.(ms) 26 | username: "" # redis username 27 | password: "" # redis password 28 | db: 1 # redis database 29 | pool_size: 60 # maximum number of socket connections. 30 | # default is 10 connections per every CPU as reported by runtime.NumCPU. -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | # delay queue config 2 | delay_queue: 3 | bind_address: "127.0.0.1:8000" # listen address 4 | bucket_name: "dq_bucket" # bucket redis key name 5 | bucket_size: 8 # the number of delay queue bucket. 6 | # increase number could get better concurrency. 7 | queue_name: "dq_queue" # queue redis key name 8 | bucket_max_fetch_num: 200 # max fetch number of jobs in the bucket 9 | timer_fetch_interval: 1000 # fetching job interval(ms), decrease interval may get better throughout. 10 | timer_fetch_delay: 0 # fetch delay(ms), if there are still job in the bucket after the fetch, 11 | # it will delay timer_fetch_delay ms for next fetch. Default is not wait. 12 | 13 | # redis config 14 | redis: 15 | #mode: "" # redis set up. (EX: "", "cluster") default is empty. 16 | # set "cluster", if you are using redis cluster. 17 | network: "tcp" # redis network: tcp, unix. This is only for Single redis, not cluster. 18 | address: "127.0.0.1:6379" # host:port address for normal redis address. (EX: 127.0.0.1:6379). 19 | # redis cluster mode(ip1:port1,ip2:port2,ip3:port3) 20 | #dial_timeout: 5000 # dial timeout for establishing new connections. 21 | # default is 5 seconds. (ms) 22 | #read_timeout: 2000 # timeout for socket reads. If reached, commands will failwith a timeout instead 23 | # of blocking. Use value -1 for no timeout and 0 for default.(ms) 24 | #write_timeout: 2000 # timeout for socket writes. If reached, commands will fail 25 | # with a timeout instead of blocking.(ms) 26 | #username: "" # redis username 27 | #password: "" # redis password 28 | #db: 0 # redis database 29 | #pool_size: 80 # maximum number of socket connections. 30 | # default is 10 connections per every CPU as reported by runtime.NumCPU. -------------------------------------------------------------------------------- /doc/arch/delay-queue-HA.drawio: -------------------------------------------------------------------------------- 1 | 7VxLl6I4FP41LvUAAcRlaVlTc6ZrTs3UYrqXCFFpkVgx+OhfPwHC+yEKaqhWF5KbSxLud3MfIbEHJuvDH1jfLN+QCe2eJJiHHnjuSZIoCir98SjHgKIJo4CwwJbJmGLCh/ULMqLAqK5lwm2KkSBkE2uTJhrIcaBBUjQdY7RPs82Rne51oy9gjvBh6Hae+p9lkiV7CkWI6a/QWixJ9MCsZqYbqwVGrsP660lg6n+C6rUetsX4t0vdRPsECUx7YIIRIsHV+jCBtifbUGzBfS8ltdG4MXRInRv+XL+6q/2vw19/v1nvu9ed/Pb5vS8Hrex024XhY/iDJcdQQNAxnzw505KDHEocL8napiWRXm4JRis4QTbCPjcQNWnq9T+eI4ck6U/el9JtfQbtcSS7BAuTHhgHQ4BmDrj4UcVIgFQxIVpDgo+UZR8jGAK4TIAX0jC0dWLt0s3rTJEWUXNRD+/Ioh1LQqj0IaRM5UVNSDexRS42ILsrCUmmoaF6oiGi4wUkuYboReKxY5KP+BnoKw/0m6MvtYV+rqEroy+pebSp1D9YEWGyRAvk6PY0po4NF++g16pACz6GfsnXhhUkxpJVxXd/Q2jDOH5CQo7MBeguQWltovDi43fv/oESFn+w5vzC8yFVOrJSma6Va2ddFaOy8bGsECKTYQBVBd+oWGVr62IK/LORHvKFdCcQE0FDyPxbqfHUjwmGjTebt+XWJbJSzCgAIePVM/yyXMlPL4IRtGo51AK/odrEQ3CjO/R64V0/U0F5j/6PCykbY6D9JXlyeplWtP3SIvBjo/uA7mkAmtEjy7YT2mLqUJsbRXqkGhqczaP+dhATeKjWpDzw4Q2jjNkOA8KE+4l8RNL/qEK5rjSb3qPH9I6EMbyRQb5odp+Yredag+vMbiDwpU6djAvqqmFjL9PIcgB+Q0AZaEmw+8JAoLKqRtwvvUNsUelA3Ck1kLtgjlRwB3M0/J2DDaDxFmwAwK3J6I530G7lHS4yCyemOR85CJD50sNO6NNdgt5z1ek+Qa/ClzqVR0KnDBt/QVBta9c0CGrk2LTfOc5QhtzFGY81y4zd5j6blAteH3FiQ1OhoXjCgpa/pHp5UdUx6Iw23CWpzLpv5VRSKVfyX8fdj0qNvYdsSofVTxeFFf2tr2tPlEFUNoe4MjT9/0LTorIRJra79bwv5QsbnuHYP2QpM9egut5fwWO/gP8nmpVVfXpuqKxyMBiUd07FFjzqFXzWXDOgUeizZpoiK0I7PkvM+CwACnyWWuCzROVqTisfRGCqEdjbuFKABJfGMir8SFvO1vLoKvM6mUzViAVhE+JEteB/2rWlImiWOjFdFAZqShdFlk5cutcgZEHz+RZe5e0/eLw0ukAL7puiyHW2a90TxKqpPaKfzuB81+0a4SivFZ2UBADJZPfSHuivvvaCA2e29X7iOEiqDEXS6XPM2GAcLTxNIiyTuB9sGCXyP9I4aOV/rIkYumSMjSdM3VnRuQAdqNwF6GGuyYvDvF3U3aYXletGS/ddjhIrkrECJfjmBS9peHTbWjj02qAy8tbSx958sAzdfmIVa8s0fR3BkM50fea35wHEVklo48q4pzxXQNZWOlQwVdmZCTasXnQUIQl6xTTJA8cmcl9slt2ESZKavuN6yY7M2Utr3lcY21k5PLk0KNfc8X72G8fqJcVzlyyvswQp53fRPsxTTfNU4leixRhBVcQUog0NFmt4dCt7JRYl+m1kgDfJI0syQHDmSngBf5jjFFTFSUVBZTqK7/i6t8JfWC0+TpK1cJJMkTL4XHqSLNfQlU+SiZytJXdyT6BYd6+w2jA2a3bWROQL6m5ANrwrZJy9JOgGZFpDyC5KXEAmEQlfcdfdkJ/hv07iItbbkT+xLeiQm6xmzudScdhlqjNVUdsJu6TMuT+tIOq67bE/zhY1uul1625Ova8J52yz+9eGuqnpbwY1vxvRvyDUTWPpi7y8Ip3n5bPLk7fx8vX2w381Ly+rN/PytBj/+1MAW/wXW2D6Pw== -------------------------------------------------------------------------------- /pkg/redis/kv.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | func (r *redis) Get(ctx context.Context, key string) (string, error) { 9 | return r.client.Get(ctx, key).Result() 10 | } 11 | 12 | func (r *redis) Set(ctx context.Context, key string, value interface{}) error { 13 | return r.client.Set(ctx, key, value, 0).Err() 14 | } 15 | 16 | func (r *redis) SetEx(ctx context.Context, key string, value interface{}, expire time.Duration) error { 17 | return r.client.Set(ctx, key, value, expire).Err() 18 | } 19 | 20 | func (r *redis) Incr(ctx context.Context, key string) (int64, error) { 21 | return r.client.Incr(ctx, key).Result() 22 | } 23 | 24 | func (r *redis) IncrBy(ctx context.Context, key string, increment int64) (int64, error) { 25 | return r.client.IncrBy(ctx, key, increment).Result() 26 | } 27 | 28 | func (r *redis) Decr(ctx context.Context, key string) (int64, error) { 29 | return r.client.Decr(ctx, key).Result() 30 | } 31 | 32 | func (r *redis) DecrBy(ctx context.Context, key string, decrement int64) (int64, error) { 33 | return r.client.DecrBy(ctx, key, decrement).Result() 34 | } 35 | 36 | func (r *redis) MGet(ctx context.Context, keys ...string) ([]*string, error) { 37 | if len(keys) == 0 { 38 | return []*string{}, nil 39 | } 40 | 41 | interfaceSli, err := r.client.MGet(ctx, keys...).Result() 42 | if err != nil { 43 | return []*string{}, err 44 | } 45 | 46 | strSlice := make([]*string, 0, len(interfaceSli)) 47 | for _, v := range interfaceSli { 48 | value, ok := v.(string) 49 | if v == nil || !ok { 50 | strSlice = append(strSlice, nil) 51 | } else { 52 | strSlice = append(strSlice, &value) 53 | } 54 | } 55 | return strSlice, nil 56 | } 57 | 58 | func (r *redis) MSet(ctx context.Context, kvs map[string]interface{}) error { 59 | if len(kvs) == 0 { 60 | return nil 61 | } 62 | 63 | sli := make([]interface{}, 0, len(kvs)*2) 64 | for k, v := range kvs { 65 | sli = append(sli, k, v) 66 | } 67 | 68 | return r.client.MSet(ctx, sli...).Err() 69 | } 70 | 71 | func (r *redis) SetNx(ctx context.Context, key string, value interface{}) (bool, error) { 72 | return r.client.SetNX(ctx, key, value, time.Hour).Result() 73 | } 74 | 75 | func (r *redis) SetNxExpire(ctx context.Context, key string, value interface{}, expiration time.Duration) (bool, error) { 76 | return r.client.SetNX(ctx, key, value, expiration).Result() 77 | } 78 | -------------------------------------------------------------------------------- /pkg/redis/hash.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func (r *redis) HGetAll(ctx context.Context, key string) (map[string]string, error) { 8 | return r.client.HGetAll(ctx, key).Result() 9 | } 10 | 11 | func (r *redis) HMGet(ctx context.Context, key string, fields []string) ([]*string, error) { 12 | if len(fields) == 0 { 13 | return []*string{}, nil 14 | } 15 | 16 | objSli, err := r.client.HMGet(ctx, key, fields...).Result() 17 | if err != nil { 18 | return []*string{}, err 19 | } 20 | 21 | strSlice := make([]*string, 0, len(objSli)) 22 | for _, v := range objSli { 23 | value, ok := v.(string) 24 | if v == nil || !ok { 25 | strSlice = append(strSlice, nil) 26 | } else { 27 | strSlice = append(strSlice, &value) 28 | } 29 | } 30 | return strSlice, nil 31 | } 32 | 33 | func (r *redis) HGet(ctx context.Context, key string, field string) (string, error) { 34 | return r.client.HGet(ctx, key, field).Result() 35 | } 36 | 37 | func (r *redis) HMSet(ctx context.Context, key string, hash map[string]interface{}) (bool, error) { 38 | res, err := r.client.HMSet(ctx, key, hash).Result() 39 | return res, err 40 | } 41 | 42 | func (r *redis) HSet(ctx context.Context, key string, field string, value interface{}) (overwrite bool, err error) { 43 | res, err := r.client.HSet(ctx, key, field, value).Result() 44 | return res == 0, err 45 | } 46 | 47 | func (r *redis) HSetNX(ctx context.Context, key string, field string, value interface{}) (overwrite bool, err error) { 48 | return r.client.HSetNX(ctx, key, field, value).Result() 49 | } 50 | 51 | func (r *redis) HDel(ctx context.Context, key string, fields ...string) (delNum int64, err error) { 52 | return r.client.HDel(ctx, key, fields...).Result() 53 | } 54 | 55 | func (r *redis) HExists(ctx context.Context, key string, field string) (exists bool, err error) { 56 | return r.client.HExists(ctx, key, field).Result() 57 | } 58 | 59 | func (r *redis) HKeys(ctx context.Context, key string) ([]string, error) { 60 | return r.client.HKeys(ctx, key).Result() 61 | } 62 | 63 | func (r *redis) HLen(ctx context.Context, key string) (int64, error) { 64 | return r.client.HLen(ctx, key).Result() 65 | } 66 | 67 | func (r *redis) HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error) { 68 | return r.client.HIncrBy(ctx, key, field, incr).Result() 69 | } 70 | 71 | func (r *redis) HIncrByFloat(ctx context.Context, key string, field string, incr float64) (float64, error) { 72 | return r.client.HIncrByFloat(ctx, key, field, incr).Result() 73 | } 74 | -------------------------------------------------------------------------------- /queue/queue_test.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "fmt" 5 | "github.com/changsongl/delay-queue/job" 6 | "github.com/changsongl/delay-queue/pkg/lock" 7 | logmock "github.com/changsongl/delay-queue/test/mock/log" 8 | storemock "github.com/changsongl/delay-queue/test/mock/store" 9 | "github.com/golang/mock/gomock" 10 | "github.com/stretchr/testify/require" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func TestQueuePush(t *testing.T) { 16 | ctrl := gomock.NewController(t) 17 | defer ctrl.Finish() 18 | 19 | queueName := "test_queue_name" 20 | jobTopic := job.Topic("job_topic") 21 | que := fmt.Sprintf("%s_%s", queueName, jobTopic) 22 | 23 | j, err := job.New(jobTopic, "1", 1, 1, "", func(name string) lock.Locker { 24 | return nil 25 | }) 26 | require.NoError(t, err) 27 | 28 | sm := storemock.NewMockStore(ctrl) 29 | mLog := logmock.NewMockLogger(ctrl) 30 | 31 | sm.EXPECT().PushJobToQueue(queueName, que, j).Return(nil) 32 | sm.EXPECT().CollectInFlightJobNumberQueue(queueName).AnyTimes() 33 | q := New(sm, mLog, queueName) 34 | 35 | err = q.Push(j) 36 | require.NoError(t, err) 37 | } 38 | 39 | func TestQueuePop(t *testing.T) { 40 | ctrl := gomock.NewController(t) 41 | defer ctrl.Finish() 42 | queueName := "test_queue_name" 43 | jobTopic := job.Topic("job_topic") 44 | que := fmt.Sprintf("%s_%s", queueName, jobTopic) 45 | 46 | expectNV := job.NameVersion("haha") 47 | sm := storemock.NewMockStore(ctrl) 48 | mLog := logmock.NewMockLogger(ctrl) 49 | 50 | sm.EXPECT().PopJobFromQueue(que).Return(expectNV, nil) 51 | sm.EXPECT().CollectInFlightJobNumberQueue(queueName).AnyTimes() 52 | q := New(sm, mLog, queueName) 53 | 54 | nv, err := q.Pop(jobTopic) 55 | require.NoError(t, err) 56 | require.Equal(t, expectNV, nv) 57 | } 58 | 59 | func TestQueuePopWithBlockTime(t *testing.T) { 60 | ctrl := gomock.NewController(t) 61 | defer ctrl.Finish() 62 | queueName := "test_queue_name" 63 | jobTopic := job.Topic("job_topic") 64 | que := fmt.Sprintf("%s_%s", queueName, jobTopic) 65 | 66 | expectNV := job.NameVersion("haha") 67 | sm := storemock.NewMockStore(ctrl) 68 | mLog := logmock.NewMockLogger(ctrl) 69 | blockTime := 2 * time.Second 70 | 71 | sm.EXPECT().BPopJobFromQueue(que, blockTime).DoAndReturn( 72 | func(queue string, blockTime time.Duration) (job.NameVersion, error) { 73 | time.Sleep(blockTime) 74 | return expectNV, nil 75 | }, 76 | ) 77 | sm.EXPECT().CollectInFlightJobNumberQueue(queueName).AnyTimes() 78 | q := New(sm, mLog, queueName) 79 | 80 | startTime := time.Now() 81 | nv, err := q.PopWithBlockTime(jobTopic, blockTime) 82 | dur := time.Since(startTime) 83 | 84 | require.NoError(t, err) 85 | require.Equal(t, expectNV, nv) 86 | require.GreaterOrEqual(t, dur, blockTime) 87 | } 88 | -------------------------------------------------------------------------------- /test/postman/delayqueue.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "aa5da217-4697-4e35-88f4-e6fdcabc2668", 4 | "name": "delay queue", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "finish job", 10 | "request": { 11 | "method": "PUT", 12 | "header": [], 13 | "url": { 14 | "raw": "http://{{delay-queue-host}}/topic/mytopic/job/myid1", 15 | "protocol": "http", 16 | "host": [ 17 | "{{delay-queue-host}}" 18 | ], 19 | "path": [ 20 | "topic", 21 | "mytopic", 22 | "job", 23 | "myid1" 24 | ] 25 | } 26 | }, 27 | "response": [] 28 | }, 29 | { 30 | "name": "add job", 31 | "request": { 32 | "method": "POST", 33 | "header": [], 34 | "body": { 35 | "mode": "raw", 36 | "raw": "{\"id\": \"myid1\",\"delay\":10, \"ttr\":4, \"body\":\"body\"}", 37 | "options": { 38 | "raw": { 39 | "language": "json" 40 | } 41 | } 42 | }, 43 | "url": { 44 | "raw": "http://{{delay-queue-host}}/topic/mytopic/job", 45 | "protocol": "http", 46 | "host": [ 47 | "{{delay-queue-host}}" 48 | ], 49 | "path": [ 50 | "topic", 51 | "mytopic", 52 | "job" 53 | ] 54 | } 55 | }, 56 | "response": [] 57 | }, 58 | { 59 | "name": "get job", 60 | "request": { 61 | "method": "GET", 62 | "header": [], 63 | "url": { 64 | "raw": "http://{{delay-queue-host}}/topic/mytopic/job", 65 | "protocol": "http", 66 | "host": [ 67 | "{{delay-queue-host}}" 68 | ], 69 | "path": [ 70 | "topic", 71 | "mytopic", 72 | "job" 73 | ] 74 | } 75 | }, 76 | "response": [] 77 | }, 78 | { 79 | "name": "delete job", 80 | "request": { 81 | "method": "DELETE", 82 | "header": [], 83 | "url": { 84 | "raw": "http://{{delay-queue-host}}/topic/mytopic/job/myid1", 85 | "protocol": "http", 86 | "host": [ 87 | "{{delay-queue-host}}" 88 | ], 89 | "path": [ 90 | "topic", 91 | "mytopic", 92 | "job", 93 | "myid1" 94 | ] 95 | } 96 | }, 97 | "response": [] 98 | }, 99 | { 100 | "name": "ping", 101 | "request": { 102 | "method": "GET", 103 | "header": [], 104 | "url": { 105 | "raw": "http://{{delay-queue-host}}/ping", 106 | "protocol": "http", 107 | "host": [ 108 | "{{delay-queue-host}}" 109 | ], 110 | "path": [ 111 | "ping" 112 | ] 113 | } 114 | }, 115 | "response": [] 116 | } 117 | ] 118 | } -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/pkg/log" 5 | "github.com/changsongl/delay-queue/vars" 6 | "github.com/gin-gonic/gin" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | ) 12 | 13 | // RouterFunc is a function resgiter handler to gin.Engine 14 | type RouterFunc func(engine *gin.Engine) 15 | 16 | // Server interface for a basic method of http web server, 17 | // which can be ran in few step 18 | type Server interface { 19 | Init() 20 | RegisterRouters(regFunc RouterFunc) 21 | Run(addr string) error 22 | } 23 | 24 | // server is Server implementation struct, it has a gin.Engine, 25 | // to save all handlers, logger, events and env. 26 | type server struct { 27 | r *gin.Engine 28 | l log.Logger 29 | beforeStart []Event 30 | afterStop []Event 31 | env vars.Env 32 | } 33 | 34 | // Event common event function can be ran before server start, 35 | // or after server stop. 36 | type Event func() 37 | 38 | // New return a Server based on Options. 39 | func New(options ...Option) Server { 40 | s := &server{ 41 | env: vars.EnvRelease, 42 | } 43 | 44 | for _, opt := range options { 45 | opt(s) 46 | } 47 | 48 | if s.env == vars.EnvRelease { 49 | gin.SetMode(gin.ReleaseMode) 50 | } 51 | 52 | r := gin.New() 53 | s.r = r 54 | 55 | return s 56 | } 57 | 58 | // Init a server, inject the logger to gin, and register prometheus 59 | // metrics for all apis. 60 | func (s *server) Init() { 61 | s.r.Use(gin.Recovery()) 62 | 63 | if s.l != nil { 64 | s.r.Use(gin.LoggerWithConfig(gin.LoggerConfig{Output: s.l})) 65 | } else { 66 | s.r.Use(gin.LoggerWithConfig(gin.LoggerConfig{})) 67 | } 68 | 69 | WrapPProf(s.r) 70 | 71 | regMetricFunc := setServerMetricHandlerAndMiddleware() 72 | regMetricFunc(s.r) 73 | } 74 | 75 | // RegisterRouters register router functions for server 76 | func (s *server) RegisterRouters(regFunc RouterFunc) { 77 | regFunc(s.r) 78 | } 79 | 80 | // Run the server, with address. waiting for shutdown signal. 81 | func (s *server) Run(addr string) error { 82 | srv := &http.Server{ 83 | Addr: addr, 84 | Handler: s.r, 85 | } 86 | 87 | sc := newShutdownChan() 88 | 89 | go func() { 90 | term := make(chan os.Signal, 1) 91 | signal.Notify(term, os.Interrupt, os.Kill, syscall.SIGTERM, syscall.SIGQUIT) 92 | for { 93 | select { 94 | case <-term: 95 | s.l.Info("Signal stop server") 96 | shutdown(srv, sc) 97 | return 98 | } 99 | } 100 | }() 101 | 102 | s.l.Info("Run server", log.String("address", addr)) 103 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 104 | return err 105 | } 106 | 107 | if err := sc.Wait(); err != nil { 108 | return err 109 | } 110 | s.l.Info("Server is stopped") 111 | 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /job/job.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/changsongl/delay-queue/pkg/lock" 9 | ) 10 | 11 | // Job job for delay queue 12 | type Job struct { 13 | Topic Topic `json:"topic,omitempty"` 14 | ID ID `json:"id,omitempty"` 15 | Delay Delay `json:"delay,omitempty"` 16 | TTR TTR `json:"ttr,omitempty"` 17 | Body Body `json:"body,omitempty"` 18 | Version Version `json:"version,omitempty"` 19 | Mutex lock.Locker `json:"-"` 20 | } 21 | 22 | // New return a job with everything init 23 | func New(topic Topic, id ID, delay Delay, ttr TTR, body Body, lockerFunc lock.LockerFunc) (*Job, error) { 24 | j := &Job{ 25 | Topic: topic, 26 | ID: id, 27 | Delay: delay, 28 | TTR: ttr, 29 | Body: body, 30 | Version: NewVersion(), 31 | } 32 | 33 | err := j.IsValid() 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | j.Mutex = lockerFunc(j.getLockName()) 39 | 40 | return j, nil 41 | } 42 | 43 | // Get a job entity before load all information from storage 44 | func Get(topic Topic, id ID, lockerFunc lock.LockerFunc) (*Job, error) { 45 | j := &Job{ 46 | Topic: topic, 47 | ID: id, 48 | } 49 | err := j.IsValid() 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | j.Mutex = lockerFunc(j.getLockName()) 55 | return j, nil 56 | } 57 | 58 | // IsValid check job is valid. job is not nil and topic and id 59 | // is not empty 60 | func (j *Job) IsValid() error { 61 | if j.Topic == "" || j.ID == "" { 62 | return errors.New("topic or id is empty") 63 | } 64 | 65 | return nil 66 | } 67 | 68 | // IsVersionSame return whether j's version is equal to v 69 | func (j *Job) IsVersionSame(v Version) bool { 70 | return j.Version.Equal(v) 71 | } 72 | 73 | // GetName return job unique name getter 74 | func (j *Job) GetName() string { 75 | return fmt.Sprintf("%s_%s", j.Topic, j.ID) 76 | } 77 | 78 | // GetNameWithVersion return name version of job 79 | func (j *Job) GetNameWithVersion() NameVersion { 80 | return NewNameVersion(j.Topic, j.ID, j.Version) 81 | } 82 | 83 | // GetName return job lock name 84 | func (j *Job) getLockName() string { 85 | return fmt.Sprintf("%s_lock", j.GetName()) 86 | } 87 | 88 | // GetDelayTimeFromNow return how much time to wait for delaying 89 | func (j *Job) GetDelayTimeFromNow() time.Time { 90 | return time.Now().Add(time.Duration(j.Delay)) 91 | } 92 | 93 | // GetTTRTimeFromNow return how much time to wait until overtime 94 | func (j *Job) GetTTRTimeFromNow() time.Time { 95 | return time.Now().Add(time.Duration(j.TTR)) 96 | } 97 | 98 | // Lock lock the job 99 | func (j *Job) Lock() error { 100 | return j.Mutex.Lock() 101 | } 102 | 103 | // Unlock unlock the job 104 | func (j *Job) Unlock() (bool, error) { 105 | return j.Mutex.Unlock() 106 | } 107 | 108 | // SetVersion set job version by nano ts 109 | func (j *Job) SetVersion(ts int64) { 110 | j.Version.t = time.Unix(ts/1e9, ts%1e9) 111 | } 112 | -------------------------------------------------------------------------------- /timer/timer.go: -------------------------------------------------------------------------------- 1 | package timer 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/changsongl/delay-queue/pkg/log" 9 | ) 10 | 11 | // TaskFunc only task function can be added to 12 | // the timer. 13 | type TaskFunc func() (hasMore bool, err error) 14 | 15 | // Timer is for processing task. it checks buckets 16 | // for popping jobs. it will put ready jobs to queue. 17 | type Timer interface { 18 | AddTask(taskFunc TaskFunc) 19 | Run() 20 | Close() 21 | } 22 | 23 | // timer is Timer implementation struct. 24 | type timer struct { 25 | wg sync.WaitGroup // wait group for quit 26 | tasks []taskStub // task stub 27 | once sync.Once // once 28 | l log.Logger // logger 29 | taskInterval time.Duration // fetch interval 30 | taskDelay time.Duration // fetch delay when bucket has more jobs after a fetching. Default no wait. 31 | } 32 | 33 | // taskStub task stub for function itself and context, 34 | // and cancel function for this task. 35 | type taskStub struct { 36 | f TaskFunc 37 | ctx context.Context 38 | cancel context.CancelFunc 39 | l log.Logger 40 | } 41 | 42 | // New create a new timer for loading ready jobs from bucket 43 | func New(l log.Logger, taskInterval, taskDelay time.Duration) Timer { 44 | return &timer{ 45 | wg: sync.WaitGroup{}, 46 | l: l.WithModule("timer"), 47 | taskInterval: taskInterval, 48 | taskDelay: taskDelay, 49 | } 50 | } 51 | 52 | // AddTask add task to timer 53 | func (t *timer) AddTask(taskFunc TaskFunc) { 54 | ctx, cancelFunc := context.WithCancel(context.Background()) 55 | task := taskStub{ 56 | f: taskFunc, 57 | ctx: ctx, 58 | cancel: cancelFunc, 59 | l: t.l, 60 | } 61 | t.tasks = append(t.tasks, task) 62 | } 63 | 64 | // Run start all tasks, and wait all task is done 65 | func (t *timer) Run() { 66 | t.wg.Add(len(t.tasks)) 67 | 68 | for _, task := range t.tasks { 69 | go func(task taskStub) { 70 | defer t.wg.Done() 71 | task.run(t.taskInterval, t.taskDelay) 72 | }(task) 73 | } 74 | 75 | t.wg.Wait() 76 | } 77 | 78 | // Close call all task cancel function to stop all tasks 79 | func (t *timer) Close() { 80 | t.once.Do( 81 | func() { 82 | for _, task := range t.tasks { 83 | task.cancel() 84 | } 85 | }, 86 | ) 87 | } 88 | 89 | // run a task, and wait for context is done. 90 | // this can be implement with more thinking. 91 | func (task taskStub) run(fetchInterval, fetchDelay time.Duration) { 92 | for { 93 | select { 94 | case <-task.ctx.Done(): 95 | return 96 | default: 97 | hasMore, err := task.f() 98 | if err != nil { 99 | task.l.Error("task.f task run failed", log.Error(err)) 100 | time.Sleep(fetchInterval) 101 | continue 102 | } else if !hasMore { 103 | time.Sleep(fetchInterval) 104 | continue 105 | } 106 | 107 | // have more jobs, wait delay time to fetch next time 108 | time.Sleep(fetchDelay) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /queue/queue.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "fmt" 5 | "github.com/changsongl/delay-queue/job" 6 | "github.com/changsongl/delay-queue/pkg/log" 7 | "github.com/changsongl/delay-queue/store" 8 | "github.com/prometheus/client_golang/prometheus" 9 | "strings" 10 | "sync" 11 | "time" 12 | ) 13 | 14 | var ( 15 | metricOnce sync.Once 16 | ) 17 | 18 | // Queue is a queue for ready jobs. 19 | type Queue interface { 20 | Push(*job.Job) error 21 | Pop(topic job.Topic) (job.NameVersion, error) 22 | PopWithBlockTime(topic job.Topic, blockTime time.Duration) (job.NameVersion, error) 23 | } 24 | 25 | // queue is Queue implementation struct. 26 | type queue struct { 27 | s store.Store // storage 28 | prefix string // prefix 29 | onFlightJobGauge *prometheus.GaugeVec // on flight jobs number in bucket 30 | l log.Logger // logger 31 | } 32 | 33 | // New a queue with a prefix, and storage for queue. 34 | func New(s store.Store, l log.Logger, name string) Queue { 35 | q := &queue{ 36 | s: s, 37 | prefix: name, 38 | l: l, 39 | } 40 | q.CollectMetrics() 41 | return q 42 | } 43 | 44 | // Push a job to queue by job's topic 45 | func (r *queue) Push(j *job.Job) error { 46 | que := r.getQueueName(j.Topic) 47 | prefix := r.getQueuePrefix() 48 | return r.s.PushJobToQueue(prefix, que, j) 49 | } 50 | 51 | // Pop a job from queue by job's topic with default block time 52 | func (r *queue) Pop(topic job.Topic) (job.NameVersion, error) { 53 | que := r.getQueueName(topic) 54 | return r.s.PopJobFromQueue(que) 55 | } 56 | 57 | // PopWithBlockTime Pop a job from queue by job's topic with a block time 58 | func (r *queue) PopWithBlockTime(topic job.Topic, blockTime time.Duration) (job.NameVersion, error) { 59 | que := r.getQueueName(topic) 60 | return r.s.BPopJobFromQueue(que, blockTime) 61 | } 62 | 63 | // getQueueName based on topic of job and the queue prefix. 64 | func (r *queue) getQueueName(topic job.Topic) string { 65 | return fmt.Sprintf("%s_%s", r.prefix, topic) 66 | } 67 | 68 | // getQueuePrefix get queue prefix 69 | func (r *queue) getQueuePrefix() string { 70 | return r.prefix 71 | } 72 | 73 | // CollectMetrics collect prometheus metrics for in flight jobs in queue 74 | func (r *queue) CollectMetrics() { 75 | r.onFlightJobGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 76 | Name: "delay_queue_in_flight_jobs_numbers_in_queue", 77 | Help: "Gauge of the number of inflight jobs in each queue", 78 | }, []string{"topic"}) 79 | 80 | metricOnce.Do(func() { 81 | err := prometheus.Register(r.onFlightJobGauge) 82 | if err != nil { 83 | r.l.Error("prometheus.Register failed", log.Error(err)) 84 | return 85 | } 86 | }) 87 | 88 | go func() { 89 | // TODO: graceful shutdown 90 | for { 91 | queueMapJobNum, err := r.s.CollectInFlightJobNumberQueue(r.prefix) 92 | if err != nil { 93 | r.l.Error("b.s.CollectInFlightJobNumberBucket failed", log.Error(err)) 94 | } 95 | 96 | for queueName, num := range queueMapJobNum { 97 | topicName := strings.TrimLeft(queueName, r.getQueuePrefix()) 98 | r.onFlightJobGauge.WithLabelValues(topicName).Set(float64(num)) 99 | } 100 | 101 | time.Sleep(30 * time.Second) 102 | } 103 | }() 104 | } 105 | -------------------------------------------------------------------------------- /pool/pool.go: -------------------------------------------------------------------------------- 1 | package pool 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/changsongl/delay-queue/job" 7 | "github.com/changsongl/delay-queue/pkg/log" 8 | "github.com/changsongl/delay-queue/store" 9 | ) 10 | 11 | var ( 12 | // ErrJobNotExist error job is not exists 13 | ErrJobNotExist = errors.New("job is not exists") 14 | // ErrVersionNotSame error job version is not same 15 | ErrVersionNotSame = errors.New("version is not same") 16 | ) 17 | 18 | // Pool is an interface for manage information of jobs. 19 | type Pool interface { 20 | CreateJob(topic job.Topic, id job.ID, 21 | delay job.Delay, ttr job.TTR, body job.Body, override bool) (*job.Job, error) 22 | LoadReadyJob(topic job.Topic, id job.ID, version job.Version) (*job.Job, error) 23 | DeleteJob(topic job.Topic, id job.ID) error 24 | } 25 | 26 | // pool is Pool implementation struct 27 | type pool struct { 28 | s store.Store 29 | l log.Logger 30 | } 31 | 32 | // New a pool with logger and storage 33 | func New(s store.Store, l log.Logger) Pool { 34 | return pool{s: s, l: l.WithModule("pool")} 35 | } 36 | 37 | // CreateJob lock the job and save job into storage 38 | func (p pool) CreateJob(topic job.Topic, id job.ID, 39 | delay job.Delay, ttr job.TTR, body job.Body, override bool) (*job.Job, error) { 40 | 41 | j, err := job.New(topic, id, delay, ttr, body, p.s.GetLock) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | err = j.Lock() 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | defer func() { 52 | if ok, unlockErr := j.Unlock(); !ok || unlockErr != nil { 53 | p.l.Error( 54 | "j.Unlock failed", 55 | log.String("job", j.GetName()), 56 | log.Error(unlockErr), 57 | log.Bool("ok", ok), 58 | ) 59 | } 60 | }() 61 | 62 | if override { 63 | err = p.s.ReplaceJob(j) 64 | } else { 65 | err = p.s.CreateJob(j) 66 | } 67 | 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | return j, err 73 | } 74 | 75 | // LoadReadyJob load ready job which is just gotten from bucket. this method will check 76 | // job version is still same. If not same, then it means the just has been replaced, so 77 | // this job should not process anymore. 78 | func (p pool) LoadReadyJob(topic job.Topic, id job.ID, version job.Version) (*job.Job, error) { 79 | j, err := job.Get(topic, id, p.s.GetLock) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | err = p.s.LoadJob(j) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | if !j.IsVersionSame(version) { 90 | return nil, ErrVersionNotSame 91 | } 92 | 93 | return j, nil 94 | } 95 | 96 | // DeleteJob a job, it will prevent job to be send to user. 97 | func (p pool) DeleteJob(topic job.Topic, id job.ID) error { 98 | j, err := job.Get(topic, id, p.s.GetLock) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | err = j.Lock() 104 | if err != nil { 105 | return err 106 | } 107 | 108 | defer func() { 109 | if ok, unlockErr := j.Unlock(); !ok || unlockErr != nil { 110 | p.l.Error( 111 | "j.Unlock failed", 112 | log.String("job", j.GetName()), 113 | log.Error(unlockErr), 114 | log.Bool("ok", ok), 115 | ) 116 | } 117 | }() 118 | 119 | result, err := p.s.DeleteJob(j) 120 | if err != nil { 121 | return err 122 | } else if !result { 123 | return ErrJobNotExist 124 | } 125 | 126 | return nil 127 | } 128 | -------------------------------------------------------------------------------- /pkg/encode/compress.go: -------------------------------------------------------------------------------- 1 | package encode 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "github.com/changsongl/delay-queue/job" 7 | ) 8 | 9 | // |tag|[length]|data 10 | 11 | // tag s 12 | const ( 13 | TagID uint64 = iota 14 | TagTopic 15 | TagDelay 16 | TagTTR 17 | TagBody 18 | TagVersion 19 | ) 20 | 21 | const ( 22 | // TagLength tag length 23 | TagLength = 1 24 | 25 | // MaxUInt64Length variant max uint64 length 26 | MaxUInt64Length = 10 27 | ) 28 | 29 | type compress struct { 30 | } 31 | 32 | // NewCompress create a json encoder 33 | func NewCompress() Encoder { 34 | return &compress{} 35 | } 36 | 37 | // Encode compress encode, not using reflect 38 | func (c *compress) Encode(j *job.Job) ([]byte, error) { 39 | buf := make([]byte, c.bufLength(j)) 40 | written := 0 41 | if !j.Delay.IsEmpty() { 42 | written += c.PutUInt64(TagDelay, uint64(j.Delay), buf[written:]) 43 | } 44 | if !j.TTR.IsEmpty() { 45 | written += c.PutUInt64(TagTTR, uint64(j.TTR), buf[written:]) 46 | } 47 | if !j.ID.IsEmpty() { 48 | written += c.PutString(TagID, string(j.ID), buf[written:]) 49 | } 50 | if !j.Body.IsEmpty() { 51 | written += c.PutString(TagBody, string(j.Body), buf[written:]) 52 | } 53 | if !j.Topic.IsEmpty() { 54 | written += c.PutString(TagTopic, string(j.Topic), buf[written:]) 55 | } 56 | written += c.PutUInt64(TagVersion, j.Version.UInt64(), buf[written:]) 57 | 58 | return buf[:written], nil 59 | } 60 | 61 | // Decode compress decode 62 | func (c *compress) Decode(b []byte, j *job.Job) error { 63 | index := 0 64 | for index < len(b) { 65 | tag, err := binary.ReadUvarint(bytes.NewBuffer(b[index:])) 66 | if err != nil { 67 | return err 68 | } 69 | index++ 70 | 71 | switch tag { 72 | case TagID: 73 | id, indexInc := c.ReadString(b[index:]) 74 | j.ID = job.ID(id) 75 | index += indexInc 76 | case TagTopic: 77 | topic, indexInc := c.ReadString(b[index:]) 78 | j.Topic = job.Topic(topic) 79 | index += indexInc 80 | case TagBody: 81 | body, indexInc := c.ReadString(b[index:]) 82 | j.Body = job.Body(body) 83 | index += indexInc 84 | case TagTTR: 85 | ttr, indexInc := c.ReadUint64(b[index:]) 86 | j.TTR = job.TTR(ttr) 87 | index += indexInc 88 | case TagDelay: 89 | delay, indexInc := c.ReadUint64(b[index:]) 90 | j.Delay = job.Delay(delay) 91 | index += indexInc 92 | case TagVersion: 93 | ts, indexInc := c.ReadUint64(b[index:]) 94 | j.SetVersion(int64(ts)) 95 | index += indexInc 96 | } 97 | } 98 | return nil 99 | } 100 | 101 | func (c *compress) bufLength(j *job.Job) int { 102 | l := (TagLength+MaxUInt64Length)*5 + len(j.ID) + len(j.Topic) 103 | if j.Body != "" { 104 | l += TagLength + MaxUInt64Length + len(j.Body) 105 | } 106 | return l 107 | } 108 | 109 | func (c *compress) ReadUint64(buf []byte) (uint64, int) { 110 | return binary.Uvarint(buf) 111 | } 112 | 113 | func (c *compress) PutUInt64(tag uint64, num uint64, buf []byte) int { 114 | written := binary.PutUvarint(buf, tag) 115 | written += binary.PutUvarint(buf[written:], num) 116 | return written 117 | } 118 | 119 | func (c *compress) ReadString(buf []byte) (string, int) { 120 | l, inc := binary.Uvarint(buf) 121 | end := inc + int(l) 122 | return string(buf[inc:end]), end 123 | } 124 | 125 | func (c *compress) PutString(tag uint64, str string, buf []byte) int { 126 | l := len(str) 127 | written := binary.PutUvarint(buf, tag) 128 | written += binary.PutUvarint(buf[written:], uint64(l)) 129 | chs := make([]uint8, 0, l) 130 | for _, ch := range str { 131 | chs = append(chs, uint8(ch)) 132 | } 133 | 134 | for _, ch := range []byte(str) { 135 | buf[written] = ch 136 | written++ 137 | } 138 | 139 | return written 140 | } 141 | -------------------------------------------------------------------------------- /test/mock/bucket/bucket.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: bucket.go 3 | 4 | // Package mock_bucket is a generated GoMock package. 5 | package mock_bucket 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | job "github.com/changsongl/delay-queue/job" 11 | gomock "github.com/golang/mock/gomock" 12 | ) 13 | 14 | // MockBucket is a mock of Bucket interface. 15 | type MockBucket struct { 16 | ctrl *gomock.Controller 17 | recorder *MockBucketMockRecorder 18 | } 19 | 20 | // MockBucketMockRecorder is the mock recorder for MockBucket. 21 | type MockBucketMockRecorder struct { 22 | mock *MockBucket 23 | } 24 | 25 | // NewMockBucket creates a new mock instance. 26 | func NewMockBucket(ctrl *gomock.Controller) *MockBucket { 27 | mock := &MockBucket{ctrl: ctrl} 28 | mock.recorder = &MockBucketMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockBucket) EXPECT() *MockBucketMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // CreateJob mocks base method. 38 | func (m *MockBucket) CreateJob(j *job.Job, isTTR bool) error { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "CreateJob", j, isTTR) 41 | ret0, _ := ret[0].(error) 42 | return ret0 43 | } 44 | 45 | // CreateJob indicates an expected call of CreateJob. 46 | func (mr *MockBucketMockRecorder) CreateJob(j, isTTR interface{}) *gomock.Call { 47 | mr.mock.ctrl.T.Helper() 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateJob", reflect.TypeOf((*MockBucket)(nil).CreateJob), j, isTTR) 49 | } 50 | 51 | // GetBucketJobs mocks base method. 52 | func (m *MockBucket) GetBucketJobs(bid uint64) ([]job.NameVersion, error) { 53 | m.ctrl.T.Helper() 54 | ret := m.ctrl.Call(m, "GetBucketJobs", bid) 55 | ret0, _ := ret[0].([]job.NameVersion) 56 | ret1, _ := ret[1].(error) 57 | return ret0, ret1 58 | } 59 | 60 | // GetBucketJobs indicates an expected call of GetBucketJobs. 61 | func (mr *MockBucketMockRecorder) GetBucketJobs(bid interface{}) *gomock.Call { 62 | mr.mock.ctrl.T.Helper() 63 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBucketJobs", reflect.TypeOf((*MockBucket)(nil).GetBucketJobs), bid) 64 | } 65 | 66 | // GetBuckets mocks base method. 67 | func (m *MockBucket) GetBuckets() []uint64 { 68 | m.ctrl.T.Helper() 69 | ret := m.ctrl.Call(m, "GetBuckets") 70 | ret0, _ := ret[0].([]uint64) 71 | return ret0 72 | } 73 | 74 | // GetBuckets indicates an expected call of GetBuckets. 75 | func (mr *MockBucketMockRecorder) GetBuckets() *gomock.Call { 76 | mr.mock.ctrl.T.Helper() 77 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBuckets", reflect.TypeOf((*MockBucket)(nil).GetBuckets)) 78 | } 79 | 80 | // GetMaxFetchNum mocks base method. 81 | func (m *MockBucket) GetMaxFetchNum() uint64 { 82 | m.ctrl.T.Helper() 83 | ret := m.ctrl.Call(m, "GetMaxFetchNum") 84 | ret0, _ := ret[0].(uint64) 85 | return ret0 86 | } 87 | 88 | // GetMaxFetchNum indicates an expected call of GetMaxFetchNum. 89 | func (mr *MockBucketMockRecorder) GetMaxFetchNum() *gomock.Call { 90 | mr.mock.ctrl.T.Helper() 91 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxFetchNum", reflect.TypeOf((*MockBucket)(nil).GetMaxFetchNum)) 92 | } 93 | 94 | // SetMaxFetchNum mocks base method. 95 | func (m *MockBucket) SetMaxFetchNum(num uint64) { 96 | m.ctrl.T.Helper() 97 | m.ctrl.Call(m, "SetMaxFetchNum", num) 98 | } 99 | 100 | // SetMaxFetchNum indicates an expected call of SetMaxFetchNum. 101 | func (mr *MockBucketMockRecorder) SetMaxFetchNum(num interface{}) *gomock.Call { 102 | mr.mock.ctrl.T.Helper() 103 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMaxFetchNum", reflect.TypeOf((*MockBucket)(nil).SetMaxFetchNum), num) 104 | } 105 | -------------------------------------------------------------------------------- /job/job_test.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/changsongl/delay-queue/pkg/lock" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | var ( 15 | lockFunc = func(name string) lock.Locker { 16 | return &locker{} 17 | } 18 | ) 19 | 20 | type locker struct { 21 | m sync.Mutex 22 | } 23 | 24 | func (l *locker) Unlock() (bool, error) { 25 | return true, nil 26 | } 27 | 28 | func (l *locker) Lock() error { 29 | return nil 30 | } 31 | 32 | func TestNew(t *testing.T) { 33 | delay, ttr, body := Delay(10*time.Second), TTR(5*time.Second), Body("") 34 | emptyErrMsg := errors.New("topic or id is empty") 35 | 36 | testCases := []struct { 37 | topic Topic 38 | id ID 39 | err error 40 | }{ 41 | {topic: "", id: "", err: emptyErrMsg}, 42 | {topic: "t1222", id: "id1", err: nil}, 43 | } 44 | 45 | for _, tc := range testCases { 46 | job, err := New(tc.topic, tc.id, delay, ttr, body, lockFunc) 47 | if tc.err == nil { 48 | require.NoError(t, err, "job should have no error: %s", err) 49 | require.Equal(t, tc.topic, job.Topic, "wrong topic %s (expect: %s)", job.Topic, tc.topic) 50 | require.Equal(t, tc.id, job.ID, "wrong id %s (expect: %s)", job.ID, tc.id) 51 | require.Equal(t, delay, job.Delay, "wrong delay %s (expect: %s)", job.Delay, delay) 52 | require.Equal(t, ttr, job.TTR, "wrong ttr %s (expect: %s)", job.TTR, ttr) 53 | require.Equal(t, body, job.Body, "wrong body %s (expect: %s)", job.Body, body) 54 | } else { 55 | require.Error(t, err, "job should have error") 56 | require.Equal(t, err, tc.err, "job should have error %s (expect: %s)", err, tc.err) 57 | } 58 | } 59 | } 60 | 61 | func TestGet(t *testing.T) { 62 | emptyErrMsg := errors.New("topic or id is empty") 63 | testCases := []struct { 64 | topic Topic 65 | id ID 66 | err error 67 | }{ 68 | {topic: "", id: "", err: emptyErrMsg}, 69 | {topic: "t1222", id: "id1", err: nil}, 70 | } 71 | 72 | for _, tc := range testCases { 73 | job, err := Get(tc.topic, tc.id, lockFunc) 74 | if tc.err == nil { 75 | require.NoError(t, err, "job should have no error: %s", err) 76 | require.Equal(t, tc.topic, job.Topic, "wrong topic %s (expect: %s)", job.Topic, tc.topic) 77 | require.Equal(t, tc.id, job.ID, "wrong id %s (expect: %s)", job.ID, tc.id) 78 | } else { 79 | require.Error(t, err, "job should have error") 80 | require.Equal(t, err, tc.err, "job should have error %s (expect: %s)", err, tc.err) 81 | } 82 | } 83 | } 84 | 85 | func TestIsVersionSame(t *testing.T) { 86 | job, err := New("topic", "id111", Delay(time.Second), TTR(time.Second), "", lockFunc) 87 | require.NoError(t, err, "Get should no error") 88 | 89 | same := job.IsVersionSame(job.Version) 90 | require.Equal(t, true, same, "version should be same") 91 | 92 | notSame := job.IsVersionSame(Version{t: time.Now()}) 93 | require.Equal(t, false, notSame, "version should be not same") 94 | } 95 | 96 | func TestGetter(t *testing.T) { 97 | job, err := New("topic", "id111", Delay(time.Second), TTR(time.Second), "", lockFunc) 98 | require.NoError(t, err, "Get should no error") 99 | 100 | jName := fmt.Sprintf("%s_%s", job.Topic, job.ID) 101 | require.Equal(t, jName, job.GetName(), "job name should be same") 102 | require.EqualValues(t, fmt.Sprintf("%s_%s_%s", job.Topic, job.ID, job.Version), job.GetNameWithVersion(), 103 | "name with version should be same") 104 | require.EqualValues(t, fmt.Sprintf("%s_%s", jName, "lock"), job.getLockName(), 105 | "lock name should be same") 106 | 107 | // TODO: GetDelayTimeFromNow,GetTTRTimeFromNow need mock time.Now method 108 | err = job.Lock() 109 | require.NoError(t, err, "Lock should no error") 110 | 111 | result, err := job.Unlock() 112 | require.NoError(t, err, "Unlock should no error") 113 | require.EqualValues(t, true, result, "Unlock result should be true") 114 | } 115 | -------------------------------------------------------------------------------- /bucket/bucket_test.go: -------------------------------------------------------------------------------- 1 | package bucket 2 | 3 | import ( 4 | "errors" 5 | "github.com/changsongl/delay-queue/job" 6 | logmock "github.com/changsongl/delay-queue/test/mock/log" 7 | lockmock "github.com/changsongl/delay-queue/test/mock/pkg/lock" 8 | storemock "github.com/changsongl/delay-queue/test/mock/store" 9 | "github.com/golang/mock/gomock" 10 | "github.com/stretchr/testify/require" 11 | "testing" 12 | ) 13 | 14 | func TestBucketCreateJob(t *testing.T) { 15 | ctrl := gomock.NewController(t) 16 | defer ctrl.Finish() 17 | 18 | sm := storemock.NewMockStore(ctrl) 19 | lockMk := lockmock.NewMockLocker(ctrl) 20 | mLog := logmock.NewMockLogger(ctrl) 21 | 22 | sm.EXPECT().GetLock(gomock.All()).Return(lockMk).AnyTimes() 23 | b := New(sm, mLog, 10, "test_bucket") 24 | 25 | // case1: no error 26 | sm.EXPECT().CreateJobInBucket(gomock.Eq("test_bucket_1"), gomock.All(), gomock.All()).Return(nil) 27 | sm.EXPECT().CollectInFlightJobNumberBucket(gomock.Any()).AnyTimes() 28 | err := b.CreateJob(nil, true) 29 | require.NoError(t, err, "first create should no error") 30 | 31 | // case2: has error 32 | expectErr := errors.New("expect error") 33 | sm.EXPECT().CreateJobInBucket(gomock.Eq("test_bucket_2"), gomock.All(), gomock.All()).Return(expectErr) 34 | sm.EXPECT().CollectInFlightJobNumberBucket(gomock.Any()).AnyTimes() 35 | 36 | err = b.CreateJob(nil, true) 37 | require.Equal(t, expectErr, err, "second create should be expect error") 38 | } 39 | 40 | func TestBucketGetBuckets(t *testing.T) { 41 | ctrl := gomock.NewController(t) 42 | defer ctrl.Finish() 43 | 44 | sm := storemock.NewMockStore(ctrl) 45 | lockMk := lockmock.NewMockLocker(ctrl) 46 | mLog := logmock.NewMockLogger(ctrl) 47 | 48 | sm.EXPECT().GetLock(gomock.All()).Return(lockMk).AnyTimes() 49 | sm.EXPECT().CollectInFlightJobNumberBucket(gomock.Any()).AnyTimes() 50 | b := New(sm, mLog, 2, "test_bucket") 51 | bucketNames := b.GetBuckets() 52 | 53 | expectNames := []uint64{ 54 | 0, 1, 55 | } 56 | 57 | for i, bucketName := range bucketNames { 58 | if i > len(expectNames) { 59 | t.Error("it is greater than expecting length") 60 | t.FailNow() 61 | } 62 | 63 | require.Equal(t, expectNames[i], bucketName, "bucket names are not equal") 64 | } 65 | } 66 | 67 | func TestBucketGetBucketJobs(t *testing.T) { 68 | ctrl := gomock.NewController(t) 69 | defer ctrl.Finish() 70 | 71 | sm := storemock.NewMockStore(ctrl) 72 | lockMk := lockmock.NewMockLocker(ctrl) 73 | mLog := logmock.NewMockLogger(ctrl) 74 | 75 | sm.EXPECT().GetLock(gomock.All()).Return(lockMk).AnyTimes() 76 | sm.EXPECT().CollectInFlightJobNumberBucket(gomock.Any()).AnyTimes() 77 | b := New(sm, mLog, 2, "test_bucket") 78 | 79 | expectErr := errors.New("error GetReadyJobsInBucket") 80 | sm.EXPECT().GetReadyJobsInBucket(gomock.Eq("test_bucket_0"), gomock.All()).Return(nil, expectErr) 81 | versions, err := b.GetBucketJobs(0) 82 | require.Equal(t, expectErr, err, "it should be expecting") 83 | require.Nil(t, versions, "version names should be nil") 84 | 85 | expectNvs := []job.NameVersion{ 86 | "nv1", "nv2", 87 | } 88 | sm.EXPECT().GetReadyJobsInBucket(gomock.Eq("test_bucket_1"), gomock.All()).Return(expectNvs, nil) 89 | versions, err = b.GetBucketJobs(1) 90 | require.NoError(t, err, "it should have no error") 91 | require.Equal(t, expectNvs, versions, "version names should be equal") 92 | } 93 | 94 | func TestBucketFetchNum(t *testing.T) { 95 | ctrl := gomock.NewController(t) 96 | defer ctrl.Finish() 97 | 98 | sm := storemock.NewMockStore(ctrl) 99 | lockMk := lockmock.NewMockLocker(ctrl) 100 | mLog := logmock.NewMockLogger(ctrl) 101 | 102 | sm.EXPECT().GetLock(gomock.All()).Return(lockMk).AnyTimes() 103 | sm.EXPECT().CollectInFlightJobNumberBucket(gomock.Any()).AnyTimes() 104 | b := New(sm, mLog, 2, "test_bucket") 105 | require.Equal(t, DefaultMaxFetchNum, b.GetMaxFetchNum(), "fetch number should be default") 106 | 107 | var newNum uint64 = 30 108 | b.SetMaxFetchNum(newNum) 109 | require.Equal(t, newNum, b.GetMaxFetchNum(), "fetch number should be new number") 110 | } 111 | 112 | // TODO: test collect metric 113 | -------------------------------------------------------------------------------- /server/pprof.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http/pprof" 5 | "strings" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // WrapPProf Wrap adds several routes from package `net/http/pprof` to *gin.Engine object 11 | func WrapPProf(router *gin.Engine) { 12 | WrapGroup(&router.RouterGroup) 13 | } 14 | 15 | // WrapGroup adds several routes from package `net/http/pprof` to *gin.RouterGroup object 16 | func WrapGroup(router *gin.RouterGroup) { 17 | routers := []struct { 18 | Method string 19 | Path string 20 | Handler gin.HandlerFunc 21 | }{ 22 | {"GET", "/debug/pprof/", IndexHandler()}, 23 | {"GET", "/debug/pprof/heap", HeapHandler()}, 24 | {"GET", "/debug/pprof/goroutine", GoroutineHandler()}, 25 | {"GET", "/debug/pprof/allocs", AllocsHandler()}, 26 | {"GET", "/debug/pprof/block", BlockHandler()}, 27 | {"GET", "/debug/pprof/threadcreate", ThreadCreateHandler()}, 28 | {"GET", "/debug/pprof/cmdline", CmdlineHandler()}, 29 | {"GET", "/debug/pprof/profile", ProfileHandler()}, 30 | {"GET", "/debug/pprof/symbol", SymbolHandler()}, 31 | {"POST", "/debug/pprof/symbol", SymbolHandler()}, 32 | {"GET", "/debug/pprof/trace", TraceHandler()}, 33 | {"GET", "/debug/pprof/mutex", MutexHandler()}, 34 | } 35 | 36 | basePath := strings.TrimSuffix(router.BasePath(), "/") 37 | var prefix string 38 | 39 | switch { 40 | case basePath == "": 41 | prefix = "" 42 | case strings.HasSuffix(basePath, "/debug"): 43 | prefix = "/debug" 44 | case strings.HasSuffix(basePath, "/debug/pprof"): 45 | prefix = "/debug/pprof" 46 | } 47 | 48 | for _, r := range routers { 49 | router.Handle(r.Method, strings.TrimPrefix(r.Path, prefix), r.Handler) 50 | } 51 | } 52 | 53 | // IndexHandler will pass the call from /debug/pprof to pprof 54 | func IndexHandler() gin.HandlerFunc { 55 | return func(ctx *gin.Context) { 56 | pprof.Index(ctx.Writer, ctx.Request) 57 | } 58 | } 59 | 60 | // HeapHandler will pass the call from /debug/pprof/heap to pprof 61 | func HeapHandler() gin.HandlerFunc { 62 | return func(ctx *gin.Context) { 63 | pprof.Handler("heap").ServeHTTP(ctx.Writer, ctx.Request) 64 | } 65 | } 66 | 67 | // GoroutineHandler will pass the call from /debug/pprof/goroutine to pprof 68 | func GoroutineHandler() gin.HandlerFunc { 69 | return func(ctx *gin.Context) { 70 | pprof.Handler("goroutine").ServeHTTP(ctx.Writer, ctx.Request) 71 | } 72 | } 73 | 74 | // AllocsHandler will pass the call from /debug/pprof/allocs to pprof 75 | func AllocsHandler() gin.HandlerFunc { 76 | return func(ctx *gin.Context) { 77 | pprof.Handler("allocs").ServeHTTP(ctx.Writer, ctx.Request) 78 | } 79 | } 80 | 81 | // BlockHandler will pass the call from /debug/pprof/block to pprof 82 | func BlockHandler() gin.HandlerFunc { 83 | return func(ctx *gin.Context) { 84 | pprof.Handler("block").ServeHTTP(ctx.Writer, ctx.Request) 85 | } 86 | } 87 | 88 | // ThreadCreateHandler will pass the call from /debug/pprof/threadcreate to pprof 89 | func ThreadCreateHandler() gin.HandlerFunc { 90 | return func(ctx *gin.Context) { 91 | pprof.Handler("threadcreate").ServeHTTP(ctx.Writer, ctx.Request) 92 | } 93 | } 94 | 95 | // CmdlineHandler will pass the call from /debug/pprof/cmdline to pprof 96 | func CmdlineHandler() gin.HandlerFunc { 97 | return func(ctx *gin.Context) { 98 | pprof.Cmdline(ctx.Writer, ctx.Request) 99 | } 100 | } 101 | 102 | // ProfileHandler will pass the call from /debug/pprof/profile to pprof 103 | func ProfileHandler() gin.HandlerFunc { 104 | return func(ctx *gin.Context) { 105 | pprof.Profile(ctx.Writer, ctx.Request) 106 | } 107 | } 108 | 109 | // SymbolHandler will pass the call from /debug/pprof/symbol to pprof 110 | func SymbolHandler() gin.HandlerFunc { 111 | return func(ctx *gin.Context) { 112 | pprof.Symbol(ctx.Writer, ctx.Request) 113 | } 114 | } 115 | 116 | // TraceHandler will pass the call from /debug/pprof/trace to pprof 117 | func TraceHandler() gin.HandlerFunc { 118 | return func(ctx *gin.Context) { 119 | pprof.Trace(ctx.Writer, ctx.Request) 120 | } 121 | } 122 | 123 | // MutexHandler will pass the call from /debug/pprof/mutex to pprof 124 | func MutexHandler() gin.HandlerFunc { 125 | return func(ctx *gin.Context) { 126 | pprof.Handler("mutex").ServeHTTP(ctx.Writer, ctx.Request) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /api/handler/action/action.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/api/handler" 5 | "github.com/changsongl/delay-queue/dispatch" 6 | "github.com/changsongl/delay-queue/job" 7 | "github.com/changsongl/delay-queue/pkg/http" 8 | "github.com/changsongl/delay-queue/pkg/log" 9 | "github.com/changsongl/delay-queue/server" 10 | "github.com/gin-gonic/gin" 11 | "time" 12 | ) 13 | 14 | type idParam struct { 15 | ID job.ID `uri:"id" json:"id" binding:"required,max=200"` 16 | } 17 | 18 | type topicParam struct { 19 | Topic job.Topic `uri:"topic" json:"topic" binding:"required,max=50"` 20 | } 21 | 22 | type idTopicParam struct { 23 | idParam 24 | topicParam 25 | } 26 | 27 | type addParam struct { 28 | idParam 29 | Delay uint `json:"delay"` 30 | TTR uint `json:"ttr"` 31 | Body job.Body `json:"body"` 32 | Override bool `json:"override"` 33 | } 34 | 35 | type blockQuery struct { 36 | Timeout *uint `form:"timeout" json:"timeout"` 37 | } 38 | 39 | // router container all api actions 40 | type router struct { 41 | rsp http.Response 42 | logger log.Logger 43 | validator http.Validator 44 | dispatch dispatch.Dispatch 45 | } 46 | 47 | // NewHandler return a router 48 | func NewHandler(rsp http.Response, logger log.Logger, validator http.Validator, dispatch dispatch.Dispatch) handler.Handler { 49 | return &router{ 50 | rsp: rsp, 51 | logger: logger.WithModule("handler"), 52 | validator: validator, 53 | dispatch: dispatch, 54 | } 55 | } 56 | 57 | // Register return a register function for all routers 58 | func (r *router) Register() server.RouterFunc { 59 | return func(engine *gin.Engine) { 60 | engine.PUT("/topic/:topic/job/:id", r.finish) 61 | engine.POST("/topic/:topic/job", r.add) 62 | engine.GET("/topic/:topic/job", r.pop) 63 | engine.DELETE("/topic/:topic/job/:id", r.delete) 64 | } 65 | } 66 | 67 | // add action is to push job to delay queue 68 | func (r *router) add(ctx *gin.Context) { 69 | uriParam := &topicParam{} 70 | bodyParam := &addParam{} 71 | err := r.validator.Validate(ctx, uriParam, nil, bodyParam) 72 | if err != nil { 73 | r.rsp.Error(ctx, err) 74 | return 75 | } 76 | 77 | d, ttr := getDelayAndTTR(bodyParam.Delay, bodyParam.TTR) 78 | err = r.dispatch.Add(uriParam.Topic, bodyParam.ID, d, ttr, bodyParam.Body, bodyParam.Override) 79 | if err != nil { 80 | r.rsp.Error(ctx, err) 81 | return 82 | } 83 | 84 | r.rsp.Ok(ctx) 85 | } 86 | 87 | // getDelayAndTTR convert user seconds to delay object 88 | func getDelayAndTTR(d, ttr uint) (job.Delay, job.TTR) { 89 | second := uint(time.Second) 90 | return job.Delay(d * second), job.TTR(ttr * second) 91 | } 92 | 93 | // delete is for deleting job for running 94 | func (r *router) delete(ctx *gin.Context) { 95 | uriParam := &idTopicParam{} 96 | err := r.validator.Validate(ctx, uriParam, nil, nil) 97 | if err != nil { 98 | r.rsp.Error(ctx, err) 99 | return 100 | } 101 | 102 | err = r.dispatch.Delete(uriParam.Topic, uriParam.ID) 103 | if err != nil { 104 | r.rsp.Error(ctx, err) 105 | return 106 | } 107 | 108 | r.rsp.Ok(ctx) 109 | } 110 | 111 | // finish is for ack job, which is just processed by the user. 112 | // it means delay queue won't retry to send this job to user 113 | // again. 114 | func (r *router) finish(ctx *gin.Context) { 115 | uriParam := &idTopicParam{} 116 | err := r.validator.Validate(ctx, uriParam, nil, nil) 117 | if err != nil { 118 | r.rsp.Error(ctx, err) 119 | return 120 | } 121 | 122 | err = r.dispatch.Finish(uriParam.Topic, uriParam.ID) 123 | if err != nil { 124 | r.rsp.Error(ctx, err) 125 | return 126 | } 127 | 128 | r.rsp.Ok(ctx) 129 | } 130 | 131 | // pop a job from delay queue, if there is no job in the ready queue, 132 | // then id and topic are empty 133 | func (r *router) pop(ctx *gin.Context) { 134 | uriParam := &topicParam{} 135 | queryParam := &blockQuery{} 136 | err := r.validator.Validate(ctx, uriParam, queryParam, nil) 137 | if err != nil { 138 | r.rsp.Error(ctx, err) 139 | return 140 | } 141 | 142 | var blockTime time.Duration 143 | if queryParam.Timeout != nil { 144 | blockTime = time.Duration(*queryParam.Timeout) * time.Second 145 | } 146 | 147 | j, err := r.dispatch.Pop(uriParam.Topic, blockTime) 148 | if err != nil { 149 | r.rsp.Error(ctx, err) 150 | return 151 | } 152 | 153 | r.rsp.OkWithJob(ctx, j) 154 | } 155 | -------------------------------------------------------------------------------- /test/integration/integration_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "fmt" 5 | "github.com/changsongl/delay-queue-client/client" 6 | "github.com/changsongl/delay-queue-client/consumer" 7 | "github.com/changsongl/delay-queue-client/job" 8 | "github.com/stretchr/testify/require" 9 | "math/rand" 10 | "sync/atomic" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | // TODO: All testing in this file will be improved in the future. 16 | 17 | // This is an integration test for delay queue. 18 | // It will test add job, consume and remove. 19 | func TestDelayQueueAddAndRemove(t *testing.T) { 20 | t.Parallel() 21 | // push n jobs with delay within 1 min 22 | DelayTimeSeconds := 30 23 | Jobs := 200 24 | topic, key := "TestDelayQueueAddAndRemove-topic", "TestDelayQueueAddAndRemove-set" 25 | rand.Seed(time.Now().Unix()) 26 | 27 | cli := client.NewClient(DelayQueueAddr) 28 | t.Logf("Running test for %d jobs", Jobs) 29 | 30 | t.Log("Adding test") 31 | for i := 0; i < Jobs; i++ { 32 | delayTime := rand.Intn(DelayTimeSeconds) 33 | id := fmt.Sprintf("test-%d", i) 34 | j, err := job.New(topic, id, job.DelayOption(time.Duration(delayTime)*time.Second), job.BodyOption("a body")) 35 | require.NoError(t, err) 36 | 37 | err = AddJobRecord(key, id) 38 | require.NoError(t, err) 39 | 40 | err = cli.AddJob(j) 41 | require.NoError(t, err) 42 | } 43 | 44 | t.Log("Finish adding and consume") 45 | 46 | go func() { 47 | m := make(map[string]int) 48 | // consume jobs 49 | c := consumer.New(cli, topic, consumer.WorkerNumOption(1)) 50 | ch := c.Consume() 51 | for jobMsg := range ch { 52 | id := jobMsg.GetID() 53 | //t.Logf("%+v", jobMsg) 54 | err := DeleteJobRecord(key, id) 55 | require.NoError(t, err) 56 | 57 | m[id]++ 58 | if m[id] > 1 { 59 | t.Errorf("job id (%s) consume more than 1 time", id) 60 | } 61 | 62 | err = jobMsg.Finish() 63 | require.NoError(t, err) 64 | } 65 | }() 66 | 67 | // check after 1.5 min, all jobs should be done 68 | t.Log("Sleeping") 69 | time.Sleep(50 * time.Second) 70 | 71 | num, err := RecordNumbers(key) 72 | require.NoError(t, err) 73 | require.Equal(t, int64(0), num, "Remain jobs should be empty") 74 | } 75 | 76 | // Testing ttr, consume but don't finish or delete. 77 | // Message should be consume again. 78 | func TestDelayQueueTTR(t *testing.T) { 79 | t.Parallel() 80 | 81 | topic, id := "TestDelayQueueTTR-topic", "000" 82 | j, err := job.New(topic, id, job.DelayOption(10*time.Second), job.TTROption(5*time.Second)) 83 | require.NoError(t, err) 84 | 85 | cli := client.NewClient(DelayQueueAddr) 86 | err = cli.AddJob(j) 87 | require.NoError(t, err) 88 | 89 | t.Logf("Add job: %d", time.Now().Unix()) 90 | 91 | var num int64 92 | 93 | go func() { 94 | // consume jobs 95 | c := consumer.New(cli, topic, consumer.WorkerNumOption(2)) 96 | ch := c.Consume() 97 | for jobMsg := range ch { 98 | jobID := jobMsg.GetID() 99 | t.Logf("Receive job(id: %s): %d", jobID, time.Now().Unix()) 100 | if id == jobID { 101 | v := atomic.LoadInt64(&num) 102 | if v <= 4 { 103 | atomic.AddInt64(&num, 1) 104 | } 105 | } 106 | } 107 | }() 108 | 109 | time.Sleep(35 * time.Second) 110 | require.LessOrEqual(t, int64(4), num, "retry time should be equal") 111 | } 112 | 113 | //Testing ttr, consume but don't finish or delete. 114 | //Message should be consume again. 115 | func TestDelayQueueBlockPop(t *testing.T) { 116 | t.Parallel() 117 | 118 | topic, id := "TestDelayQueueBlockPop-topic", "111" 119 | j, err := job.New(topic, id, job.DelayOption(0*time.Second)) 120 | require.NoError(t, err) 121 | 122 | blockTime := 5 * time.Second 123 | 124 | cli := client.NewClient(DelayQueueAddr) 125 | 126 | var totalTime time.Duration 127 | go func() { 128 | // consume jobs 129 | c := consumer.New(cli, topic, consumer.WorkerNumOption(1), consumer.PopTimeoutOption(blockTime)) 130 | ch := c.Consume() 131 | startTime := time.Now() 132 | for jobMsg := range ch { 133 | jobID := jobMsg.GetID() 134 | t.Logf("Receive job(id: %s): %d", jobID, time.Now().Unix()) 135 | if id == jobID { 136 | totalTime += time.Since(startTime) 137 | } 138 | } 139 | }() 140 | 141 | time.Sleep(blockTime - 3*time.Second) 142 | t.Logf("Add job: %d", time.Now().Unix()) 143 | err = cli.AddJob(j) 144 | require.NoError(t, err) 145 | 146 | time.Sleep(blockTime) 147 | t.Log("total-time", totalTime) 148 | require.Greater(t, totalTime, time.Duration(0)) 149 | require.LessOrEqual(t, totalTime, blockTime) 150 | } 151 | -------------------------------------------------------------------------------- /test/mock/log/log.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: log.go 3 | 4 | // Package mock_log is a generated GoMock package. 5 | package mock_log 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | log "github.com/changsongl/delay-queue/pkg/log" 11 | gomock "github.com/golang/mock/gomock" 12 | ) 13 | 14 | // MockLogger is a mock of Logger interface. 15 | type MockLogger struct { 16 | ctrl *gomock.Controller 17 | recorder *MockLoggerMockRecorder 18 | } 19 | 20 | // MockLoggerMockRecorder is the mock recorder for MockLogger. 21 | type MockLoggerMockRecorder struct { 22 | mock *MockLogger 23 | } 24 | 25 | // NewMockLogger creates a new mock instance. 26 | func NewMockLogger(ctrl *gomock.Controller) *MockLogger { 27 | mock := &MockLogger{ctrl: ctrl} 28 | mock.recorder = &MockLoggerMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockLogger) EXPECT() *MockLoggerMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Debug mocks base method. 38 | func (m *MockLogger) Debug(msg string, fields ...log.Field) { 39 | m.ctrl.T.Helper() 40 | varargs := []interface{}{msg} 41 | for _, a := range fields { 42 | varargs = append(varargs, a) 43 | } 44 | m.ctrl.Call(m, "Debug", varargs...) 45 | } 46 | 47 | // Debug indicates an expected call of Debug. 48 | func (mr *MockLoggerMockRecorder) Debug(msg interface{}, fields ...interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | varargs := append([]interface{}{msg}, fields...) 51 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debug", reflect.TypeOf((*MockLogger)(nil).Debug), varargs...) 52 | } 53 | 54 | // Error mocks base method. 55 | func (m *MockLogger) Error(msg string, fields ...log.Field) { 56 | m.ctrl.T.Helper() 57 | varargs := []interface{}{msg} 58 | for _, a := range fields { 59 | varargs = append(varargs, a) 60 | } 61 | m.ctrl.Call(m, "Error", varargs...) 62 | } 63 | 64 | // Error indicates an expected call of Error. 65 | func (mr *MockLoggerMockRecorder) Error(msg interface{}, fields ...interface{}) *gomock.Call { 66 | mr.mock.ctrl.T.Helper() 67 | varargs := append([]interface{}{msg}, fields...) 68 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockLogger)(nil).Error), varargs...) 69 | } 70 | 71 | // Fatal mocks base method. 72 | func (m *MockLogger) Fatal(msg string, fields ...log.Field) { 73 | m.ctrl.T.Helper() 74 | varargs := []interface{}{msg} 75 | for _, a := range fields { 76 | varargs = append(varargs, a) 77 | } 78 | m.ctrl.Call(m, "Fatal", varargs...) 79 | } 80 | 81 | // Fatal indicates an expected call of Fatal. 82 | func (mr *MockLoggerMockRecorder) Fatal(msg interface{}, fields ...interface{}) *gomock.Call { 83 | mr.mock.ctrl.T.Helper() 84 | varargs := append([]interface{}{msg}, fields...) 85 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fatal", reflect.TypeOf((*MockLogger)(nil).Fatal), varargs...) 86 | } 87 | 88 | // Info mocks base method. 89 | func (m *MockLogger) Info(msg string, fields ...log.Field) { 90 | m.ctrl.T.Helper() 91 | varargs := []interface{}{msg} 92 | for _, a := range fields { 93 | varargs = append(varargs, a) 94 | } 95 | m.ctrl.Call(m, "Info", varargs...) 96 | } 97 | 98 | // Info indicates an expected call of Info. 99 | func (mr *MockLoggerMockRecorder) Info(msg interface{}, fields ...interface{}) *gomock.Call { 100 | mr.mock.ctrl.T.Helper() 101 | varargs := append([]interface{}{msg}, fields...) 102 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockLogger)(nil).Info), varargs...) 103 | } 104 | 105 | // WithModule mocks base method. 106 | func (m *MockLogger) WithModule(module string) log.Logger { 107 | m.ctrl.T.Helper() 108 | ret := m.ctrl.Call(m, "WithModule", module) 109 | ret0, _ := ret[0].(log.Logger) 110 | return ret0 111 | } 112 | 113 | // WithModule indicates an expected call of WithModule. 114 | func (mr *MockLoggerMockRecorder) WithModule(module interface{}) *gomock.Call { 115 | mr.mock.ctrl.T.Helper() 116 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithModule", reflect.TypeOf((*MockLogger)(nil).WithModule), module) 117 | } 118 | 119 | // Write mocks base method. 120 | func (m *MockLogger) Write(p []byte) (int, error) { 121 | m.ctrl.T.Helper() 122 | ret := m.ctrl.Call(m, "Write", p) 123 | ret0, _ := ret[0].(int) 124 | ret1, _ := ret[1].(error) 125 | return ret0, ret1 126 | } 127 | 128 | // Write indicates an expected call of Write. 129 | func (mr *MockLoggerMockRecorder) Write(p interface{}) *gomock.Call { 130 | mr.mock.ctrl.T.Helper() 131 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockLogger)(nil).Write), p) 132 | } 133 | -------------------------------------------------------------------------------- /bucket/bucket.go: -------------------------------------------------------------------------------- 1 | package bucket 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/changsongl/delay-queue/job" 7 | "github.com/changsongl/delay-queue/pkg/lock" 8 | "github.com/changsongl/delay-queue/pkg/log" 9 | "github.com/changsongl/delay-queue/store" 10 | "github.com/prometheus/client_golang/prometheus" 11 | "sync" 12 | "sync/atomic" 13 | "time" 14 | ) 15 | 16 | const ( 17 | // DefaultMaxFetchNum default max fetching job from bucket 18 | DefaultMaxFetchNum uint64 = 20 19 | ) 20 | 21 | var ( 22 | metricOnce sync.Once 23 | ) 24 | 25 | // Bucket interface to save jobs and repeat is searched 26 | // for jobs which are ready to process 27 | type Bucket interface { 28 | CreateJob(j *job.Job, isTTR bool) error 29 | GetBuckets() []uint64 30 | GetBucketJobs(bid uint64) ([]job.NameVersion, error) 31 | 32 | GetMaxFetchNum() uint64 33 | SetMaxFetchNum(num uint64) 34 | } 35 | 36 | // bucket implement Bucket interface 37 | type bucket struct { 38 | s store.Store // real storage 39 | size uint64 // bucket size for round robin 40 | name string // bucket name prefix 41 | count *uint64 // current bucket 42 | locks []lock.Locker // locks for buckets 43 | maxFetchNum uint64 // max number for fetching jobs 44 | l log.Logger // logger 45 | onFlightJobGauge *prometheus.GaugeVec // on flight jobs number in bucket 46 | } 47 | 48 | // New a Bucket interface object 49 | func New(s store.Store, l log.Logger, size uint64, name string) Bucket { 50 | var c uint64 51 | b := &bucket{ 52 | s: s, 53 | size: size, 54 | name: name, 55 | count: &c, 56 | l: l, 57 | maxFetchNum: DefaultMaxFetchNum, 58 | } 59 | b.locks = make([]lock.Locker, 0, size) 60 | 61 | var i uint64 62 | for i < size { 63 | b.locks = append(b.locks, s.GetLock(b.getBucketNameByID(i))) 64 | i++ 65 | } 66 | 67 | b.CollectMetrics() 68 | 69 | return b 70 | } 71 | 72 | // CreateJob create job on bucket, bucket is selected 73 | // by round robin policy 74 | func (b *bucket) CreateJob(j *job.Job, isTTR bool) error { 75 | currentBucket := b.getNextBucket() 76 | err := b.s.CreateJobInBucket(currentBucket, j, isTTR) 77 | return err 78 | } 79 | 80 | // getNextBucket get next round robin bucket 81 | func (b *bucket) getNextBucket() string { 82 | current := atomic.AddUint64(b.count, 1) 83 | return b.getBucketNameByID(current % b.size) 84 | } 85 | 86 | // getBucketNameByID return bucket name by id 87 | func (b *bucket) getBucketNameByID(id uint64) string { 88 | return fmt.Sprintf("%s_%d", b.name, id) 89 | } 90 | 91 | // GetBuckets return all bucket ids 92 | func (b *bucket) GetBuckets() []uint64 { 93 | buckets := make([]uint64, 0, b.size) 94 | var i uint64 95 | for i < b.size { 96 | buckets = append(buckets, i) 97 | i++ 98 | } 99 | 100 | return buckets 101 | } 102 | 103 | // GetBucketJobs return job.NameVersion which are ready to process. If this function 104 | // call return names and the size of name is equal to num. Then it mean it may be 105 | // more jobs are ready, but they are still in the bucket. 106 | func (b *bucket) GetBucketJobs(bid uint64) ([]job.NameVersion, error) { 107 | bucketName := b.getBucketNameByID(bid) 108 | nameVersions, err := b.s.GetReadyJobsInBucket(bucketName, uint(b.maxFetchNum)) 109 | if err != nil { 110 | return nil, err 111 | } 112 | 113 | return nameVersions, nil 114 | } 115 | 116 | // GetLock return a lock for the given bucket id. use it when get jobs from bucket, 117 | func (b *bucket) GetLock(bid uint64) (lock.Locker, error) { 118 | if bid >= b.size { 119 | return nil, errors.New("invalid bucket id to get lock") 120 | } 121 | 122 | return b.locks[bid], nil 123 | } 124 | 125 | // GetMaxFetchNum return the max number of job to fetch each time 126 | func (b *bucket) GetMaxFetchNum() uint64 { 127 | return b.maxFetchNum 128 | } 129 | 130 | // SetMaxFetchNum set the max number of job to fetch each time 131 | func (b *bucket) SetMaxFetchNum(num uint64) { 132 | b.maxFetchNum = num 133 | } 134 | 135 | func (b *bucket) CollectMetrics() { 136 | b.onFlightJobGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 137 | Name: "delay_queue_in_flight_jobs_numbers_in_bucket", 138 | Help: "Gauge of the number of inflight jobs in each bucket", 139 | }, []string{"bucket"}) 140 | 141 | metricOnce.Do(func() { 142 | err := prometheus.Register(b.onFlightJobGauge) 143 | if err != nil { 144 | b.l.Error("prometheus.Register failed", log.Error(err)) 145 | return 146 | } 147 | }) 148 | 149 | go func() { 150 | // TODO: graceful shutdown 151 | for { 152 | var i uint64 153 | for ; i < b.size; i++ { 154 | // collect 155 | bName := b.getBucketNameByID(i) 156 | num, err := b.s.CollectInFlightJobNumberBucket(bName) 157 | if err != nil { 158 | b.l.Error("b.s.CollectInFlightJobNumberBucket failed", log.Error(err)) 159 | } 160 | b.onFlightJobGauge.WithLabelValues(bName).Set(float64(num)) 161 | } 162 | 163 | time.Sleep(30 * time.Second) 164 | } 165 | }() 166 | } 167 | -------------------------------------------------------------------------------- /README_ZH.md: -------------------------------------------------------------------------------- 1 | # delay-queue 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/changsongl/delay-queue)](https://goreportcard.com/report/github.com/changsongl/delay-queue) 4 | [![Build Status](https://travis-ci.com/changsongl/delay-queue.svg?branch=main)](https://travis-ci.com/changsongl/delay-queue) 5 | [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fchangsongl%2Fdelay-queue&count_bg=%232BBC8A&title_bg=%23555555&icon=artstation.svg&icon_color=%23C7C7C7&title=Visitor&edge_flat=false)](https://hits.seeyoufarm.com) 6 | 7 | ```` 8 | ____ _ ___ 9 | | _ \ ___| | __ _ _ _ / _ \ _ _ ___ _ _ ___ 10 | | | | |/ _ \ |/ _` | | | | | | | | | | |/ _ \ | | |/ _ \ 11 | | |_| | __/ | (_| | |_| | | |_| | |_| | __/ |_| | __/ 12 | |____/ \___|_|\__,_|\__, | \__\_\\__,_|\___|\__,_|\___| 13 | |___/ 14 | ```` 15 | 16 | ### 介绍 17 | 这个项目是仿照有赞的延迟队列进行设计的。现在这个队列是通过Redis Cluster来进行存储和实现分布式高可用的, 18 | 19 | 20 | ### 高可用 21 | ![arch](./doc/arch/Delay-Queue-HA.jpg) 22 | 23 | 24 | ### 如何运行 delay queue? 25 | ````shell 26 | # clone project 27 | git clone https://github.com/changsongl/delay-queue.git 28 | 29 | # build the project 30 | make 31 | 32 | # run the project 33 | bin/delayqueue 34 | ```` 35 | 36 | ````shell 37 | # flags 38 | bin/delayqueue -help 39 | -config.file string 40 | config file (default "../../config/config.yaml") 41 | -config.type string 42 | config type: yaml, json 43 | -env string 44 | delay queue env: debug, release (default "release") 45 | -version 46 | display build info 47 | ```` 48 | 49 | ### 使用用例 50 | - ##### SDK [链接](https://github.com/changsongl/delay-queue-client) 51 | 52 | - ##### Http 53 | 54 | ```` 55 | // Push job 56 | POST 127.0.0.1:8000/topic/mytopic/job 57 | body: {"id": "myid1","delay":10, "ttr":4, "body":"body"} 58 | 59 | // response 60 | { 61 | "message": "ok", 62 | "success": true 63 | } 64 | ```` 65 | 66 | ```` 67 | // Pop job (timeout: 秒) 68 | GET 127.0.0.1:8000/topic/mytopic/job?timeout=5 69 | 70 | // response 71 | { 72 | "message": "ok", 73 | "success": true, 74 | "data": { 75 | "body": "body", 76 | "delay": 10, 77 | "id": "myid1", 78 | "topic": "mytopic", 79 | "ttr": 4 80 | } 81 | } 82 | ```` 83 | 84 | ```` 85 | // Delete job 86 | DELETE 127.0.0.1:8000/topic/mytopic/job/myid1 87 | 88 | // response 89 | { 90 | "message": "ok", 91 | "success": true 92 | } 93 | ```` 94 | 95 | ```` 96 | // Delete job 97 | PUT 127.0.0.1:8000/topic/mytopic/job/myid1 98 | 99 | // response 100 | { 101 | "message": "ok", 102 | "success": true 103 | } 104 | ```` 105 | 106 | ### 设计 107 | 108 | #### Terms 109 | 1. Job:需要异步处理的任务,是延迟队列里的基本单元。与具体的Topic关联在一起。 110 | 2. Topic:一组相同类型Job的集合(队列)。供消费者来订阅。 111 | 112 | #### 任务 113 | 1. Topic:Job类型。可以理解成具体的业务名称。 114 | 2. Id:Job的唯一标识。用来检索和删除指定的Job信息。Topic和Id的组合应该是业务中唯一的。 115 | 3. Delay:Job需要延迟的时间。单位:秒。(服务端会将其转换为绝对时间) 116 | 4. TTR(time-to-run):Job执行超时时间,超过此事件后,会将此Job再次发给消费者消费。单位:秒。 117 | 5. Body:Job的内容,供消费者做具体的业务处理,可以为json格式。 118 | 119 | 120 | #### 组件 121 | 122 | >有四个组件 123 | >1. Job Pool: 用来存放所有Job的元信息。 124 | >2. Delay Bucket: 是一组以时间为维度的有序队列,用来存放所有需要延迟的/已经被reserve的Job(这里只存放Job Id)。 125 | >3. Timer: 负责实时扫描各个Bucket,并将delay时间大于等于当前时间的Job放入到对应的Ready Queue。 126 | >4. Ready Queue: 存放处于Ready状态的Job(这里只存放Job Id),以供消费程序消费。 127 | 128 | delay-queue 129 | 130 | #### 状态 131 | >Job的状态一共有4种,同一时间下只能有一种状态。 132 | >1. ready:可执行状态,等待消费。 133 | >2. delay:不可执行状态,等待时钟周期。 134 | >3. reserved:已被消费者读取,但还未得到消费者的响应(delete、finish)。 135 | >4. deleted:已被消费完成或者已被删除。 136 | 137 | job-state 138 | 139 | ### 组件监控 140 | 项目使用普罗米修斯作为监控手段,暴露了metrics接口给普罗米修斯进行数据拉取。 141 | 你可以使用普罗米修斯和Grafana的作为自己的监控手段。 142 | 143 | ```` 144 | # HELP delay_queue_in_flight_jobs_numbers_in_bucket Gauge of the number of inflight jobs in each bucket 145 | # TYPE delay_queue_in_flight_jobs_numbers_in_bucket gauge 146 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_0"} 0 147 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_1"} 3 148 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_2"} 0 149 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_3"} 0 150 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_4"} 0 151 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_5"} 0 152 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_6"} 0 153 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_7"} 0 154 | . 155 | . 156 | . 157 | # HELP delay_queue_in_flight_jobs_numbers_in_queue Gauge of the number of inflight jobs in each queue 158 | # TYPE delay_queue_in_flight_jobs_numbers_in_queue gauge 159 | delay_queue_in_flight_jobs_numbers_in_queue{queue="dq_queue_mytopic"} 1 160 | ```` 161 | 162 | ### 项目计划 163 | 我将持续打磨这个项目,并且加入更多的功能和修复问题。我将会让这个项目可以投入到生产环境使用。 164 | 如果喜欢的话,欢迎给个星或者Fork参与进来,这里欢迎你的贡献! 165 | 166 | ### 如何贡献? 167 | 1. 在Issue里发布自己的问题或评论。 168 | 2. 我们会在问题中进行讨论,并进行设计如何开发。 169 | 3. Fork项目进行开发,并以develop分支,创建自己的分支进行开发。如fix-xxx, feature-xxx等等。 170 | 4. 开发完成后,发起PR合入develop。 171 | 5. Code Review后将会把你的代码合进分支。 172 | 173 | ### Stargazers 174 | [![Stargazers over time](https://starchart.cc/changsongl/delay-queue.svg)](https://starchart.cc/changsongl/delay-queue) 175 | 176 | ### Reference 177 | 178 | Youzan Design Concept [Youzan Link](https://tech.youzan.com/queuing_delay/) 179 | -------------------------------------------------------------------------------- /cmd/delayqueue/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "github.com/changsongl/delay-queue/api" 8 | "github.com/changsongl/delay-queue/bucket" 9 | "github.com/changsongl/delay-queue/config" 10 | "github.com/changsongl/delay-queue/dispatch" 11 | "github.com/changsongl/delay-queue/pkg/log" 12 | client "github.com/changsongl/delay-queue/pkg/redis" 13 | "github.com/changsongl/delay-queue/pool" 14 | "github.com/changsongl/delay-queue/queue" 15 | "github.com/changsongl/delay-queue/server" 16 | "github.com/changsongl/delay-queue/store/redis" 17 | "github.com/changsongl/delay-queue/timer" 18 | "github.com/changsongl/delay-queue/vars" 19 | "os" 20 | "strings" 21 | "sync" 22 | "time" 23 | ) 24 | 25 | var ( 26 | // configuration and environment 27 | configFile = flag.String("config.file", "../../config/config.yaml", "config file") 28 | configType = flag.String("config.type", "", "config type: yaml, json") 29 | env = flag.String("env", "release", "delay queue env: debug, release") 30 | version = flag.Bool("version", false, "display build info") 31 | 32 | // ErrorInvalidFileType configuration file type is invalid 33 | ErrorInvalidFileType = errors.New("invalid config file type") 34 | ) 35 | 36 | // loadConfigFlags load config file and type 37 | func loadConfigFlags() (file string, fileType config.FileType, err error) { 38 | t := *configType 39 | f := *configFile 40 | 41 | // if file type is not provided, load file type from file 42 | if t == "" { 43 | extSlice := strings.Split(f, ".") 44 | lenExt := len(extSlice) 45 | if lenExt == 0 { 46 | return "", "", ErrorInvalidFileType 47 | } 48 | 49 | t = extSlice[lenExt-1] 50 | } 51 | 52 | t = strings.ToLower(t) 53 | 54 | switch t { 55 | case "yaml": 56 | return f, config.FileTypeYAML, nil 57 | case "json": 58 | return f, config.FileTypeJSON, nil 59 | default: 60 | return "", "", ErrorInvalidFileType 61 | } 62 | } 63 | 64 | // load env 65 | func loadEnv() (vars.Env, error) { 66 | envType := vars.Env(*env) 67 | if envType != vars.EnvDebug && envType != vars.EnvRelease { 68 | return "", fmt.Errorf("invalid env (%s)", envType) 69 | } 70 | 71 | return envType, nil 72 | } 73 | 74 | // main function 75 | func main() { 76 | os.Exit(run()) 77 | } 78 | 79 | // run function 80 | func run() int { 81 | 82 | // parse flags 83 | flag.Parse() 84 | if *version { 85 | fmt.Printf(vars.BuildInfo()) 86 | return 0 87 | } 88 | 89 | file, fileType, err := loadConfigFlags() 90 | if err != nil { 91 | fmt.Printf("Load conifuration failed: %v\n", err) 92 | return 1 93 | } 94 | dqEnv, err := loadEnv() 95 | if err != nil { 96 | fmt.Printf("Load env failed: %v\n", err) 97 | return 1 98 | } 99 | 100 | // get logger 101 | l, err := createMainLog() 102 | if err != nil { 103 | fmt.Printf("Init log failed: %v\n", err) 104 | return 1 105 | } 106 | 107 | fmt.Println(logo()) 108 | 109 | // load config file 110 | l.Info("Init configuration", 111 | log.String("file", file), log.String("file.type", string(fileType))) 112 | conf := config.New() 113 | err = conf.Load(file, fileType) 114 | if err != nil { 115 | l.Error("conf.Load failed", log.Error(err)) 116 | return 1 117 | } 118 | 119 | // print config 120 | l.Info("Loaded Configuration", log.String("Configuration", conf.String())) 121 | 122 | wg := sync.WaitGroup{} 123 | wg.Add(1) 124 | 125 | // init dispatcher of delay queue, with timer, bucket, queue, job pool components. 126 | disp := dispatch.NewDispatch(l, 127 | func() (bucket.Bucket, pool.Pool, queue.Queue, timer.Timer) { 128 | cli := client.New(conf.Redis) 129 | 130 | s := redis.NewStore(cli) 131 | 132 | b := bucket.New(s, l, conf.DelayQueue.BucketSize, conf.DelayQueue.BucketName) 133 | if maxFetchNum := conf.DelayQueue.BucketMaxFetchNum; maxFetchNum != 0 { 134 | b.SetMaxFetchNum(maxFetchNum) 135 | } 136 | 137 | p := pool.New(s, l) 138 | q := queue.New(s, l, conf.DelayQueue.QueueName) 139 | t := timer.New( 140 | l, time.Duration(conf.DelayQueue.TimerFetchInterval)*time.Millisecond, 141 | time.Duration(conf.DelayQueue.TimerFetchDelay)*time.Millisecond, 142 | ) 143 | return b, p, q, t 144 | }, 145 | ) 146 | go func() { 147 | disp.Run() 148 | wg.Done() 149 | }() 150 | 151 | // run http server to receive requests from user 152 | dqAPI := api.NewAPI(l, disp) 153 | l.Info("Init server", 154 | log.String("env", string(dqEnv))) 155 | s := server.New( 156 | server.LoggerOption(l), 157 | server.EnvOption(dqEnv), 158 | server.BeforeStartEventOption(), 159 | server.AfterStopEventOption(), 160 | ) 161 | s.Init() 162 | s.RegisterRouters(dqAPI.RouterFunc()) 163 | err = s.Run(conf.DelayQueue.BindAddress) 164 | if err != nil { 165 | l.Error("s.Run failed", log.Error(err)) 166 | return 1 167 | } 168 | 169 | wg.Wait() 170 | 171 | return 0 172 | } 173 | 174 | // createMainLog create logger with name "main" 175 | func createMainLog() (log.Logger, error) { 176 | l, err := log.New() 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | return l.WithModule("main"), nil 182 | } 183 | 184 | // logo of delay queue 185 | func logo() string { 186 | return "" + 187 | " ____ _ ___ \n" + 188 | " | _ \\ ___| | __ _ _ _ / _ \\ _ _ ___ _ _ ___ \n" + 189 | " | | | |/ _ \\ |/ _` | | | | | | | | | | |/ _ \\ | | |/ _ \\\n" + 190 | " | |_| | __/ | (_| | |_| | | |_| | |_| | __/ |_| | __/\n" + 191 | " |____/ \\___|_|\\__,_|\\__, | \\__\\_\\\\__,_|\\___|\\__,_|\\___|\n" + 192 | " |___/ " 193 | } 194 | -------------------------------------------------------------------------------- /pkg/redis/zset.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | gredis "github.com/go-redis/redis/v8" 6 | "strconv" 7 | ) 8 | 9 | // Z ZSet object 10 | type Z gredis.Z 11 | 12 | // ZRevRange zset revert range 13 | func (r *redis) ZRevRange(ctx context.Context, key string, start, stop int64) ([]string, error) { 14 | return r.client.ZRevRange(ctx, key, start, stop).Result() 15 | } 16 | 17 | func (r *redis) ZRevRangeWithScores(ctx context.Context, key string, start, stop int64) ([]Z, error) { 18 | zSli, err := r.client.ZRevRangeWithScores(ctx, key, start, stop).Result() 19 | if err != nil { 20 | return []Z{}, err 21 | } 22 | 23 | results := make([]Z, 0, len(zSli)) 24 | for _, z := range zSli { 25 | results = append(results, Z(z)) 26 | } 27 | 28 | return results, nil 29 | } 30 | 31 | func (r *redis) ZRange(ctx context.Context, key string, start, stop int64) ([]string, error) { 32 | return r.client.ZRange(ctx, key, start, stop).Result() 33 | } 34 | 35 | func (r *redis) ZRangeWithScores(ctx context.Context, key string, start, stop int64) ([]Z, error) { 36 | zSli, err := r.client.ZRangeWithScores(ctx, key, start, stop).Result() 37 | if err != nil { 38 | return []Z{}, err 39 | } 40 | 41 | results := make([]Z, 0, len(zSli)) 42 | for _, z := range zSli { 43 | results = append(results, Z(z)) 44 | } 45 | 46 | return results, nil 47 | } 48 | 49 | func (r *redis) ZRangeByScoreWithScores(ctx context.Context, key string, start, stop int64) ([]Z, error) { 50 | startStr, stopStr := strconv.FormatInt(start, 10), strconv.FormatInt(stop, 10) 51 | zSli, err := r.client.ZRangeByScoreWithScores(ctx, key, &gredis.ZRangeBy{Min: startStr, Max: stopStr}).Result() 52 | if err != nil { 53 | return []Z{}, err 54 | } 55 | 56 | results := make([]Z, 0, len(zSli)) 57 | for _, z := range zSli { 58 | results = append(results, Z(z)) 59 | } 60 | 61 | return results, nil 62 | } 63 | 64 | func (r *redis) ZRangeByScoreWithScoresByOffset(ctx context.Context, 65 | key string, start, stop, offset, count int64) ([]Z, error) { 66 | startStr, stopStr := strconv.FormatInt(start, 10), strconv.FormatInt(stop, 10) 67 | zSli, err := r.client.ZRangeByScoreWithScores(ctx, key, 68 | &gredis.ZRangeBy{Min: startStr, Max: stopStr, Offset: offset, Count: count}).Result() 69 | if err != nil { 70 | return []Z{}, err 71 | } 72 | 73 | results := make([]Z, 0, len(zSli)) 74 | for _, z := range zSli { 75 | results = append(results, Z(z)) 76 | } 77 | 78 | return results, nil 79 | } 80 | 81 | func (r *redis) ZRangeByScore(ctx context.Context, key string, start, stop int64) ([]string, error) { 82 | startStr, stopStr := strconv.FormatInt(start, 10), strconv.FormatInt(stop, 10) 83 | return r.client.ZRangeByScore(ctx, key, &gredis.ZRangeBy{Min: startStr, Max: stopStr}).Result() 84 | } 85 | 86 | func (r *redis) ZRangeByScoreByOffset(ctx context.Context, 87 | key string, start, stop, offset, count int64) ([]string, error) { 88 | 89 | startStr, stopStr := strconv.FormatInt(start, 10), strconv.FormatInt(stop, 10) 90 | return r.client.ZRangeByScore(ctx, 91 | key, &gredis.ZRangeBy{Min: startStr, Max: stopStr, Offset: offset, Count: count}).Result() 92 | } 93 | 94 | func (r *redis) ZRevRank(ctx context.Context, key string, member string) (int64, error) { 95 | return r.client.ZRevRank(ctx, key, member).Result() 96 | } 97 | 98 | func (r *redis) ZRevRangeByScore(ctx context.Context, key string, start, stop int64) ([]string, error) { 99 | startStr, stopStr := strconv.FormatInt(start, 10), strconv.FormatInt(stop, 10) 100 | return r.client.ZRevRangeByScore(ctx, key, &gredis.ZRangeBy{Min: startStr, Max: stopStr}).Result() 101 | } 102 | 103 | func (r *redis) ZRevRangeByScoreByOffset(ctx context.Context, 104 | key string, start, stop, offset, count int64) ([]string, error) { 105 | 106 | startStr, stopStr := strconv.FormatInt(start, 10), strconv.FormatInt(stop, 10) 107 | return r.client.ZRevRangeByScore(ctx, 108 | key, &gredis.ZRangeBy{Min: startStr, Max: stopStr, Offset: offset, Count: count}).Result() 109 | } 110 | 111 | func (r *redis) ZRevRangeByScoreWithScores(ctx context.Context, 112 | key string, start, stop int64) ([]gredis.Z, error) { 113 | 114 | startStr, stopStr := strconv.FormatInt(start, 10), strconv.FormatInt(stop, 10) 115 | res, err := r.client.ZRevRangeByScoreWithScores(ctx, 116 | key, &gredis.ZRangeBy{Min: startStr, Max: stopStr}).Result() 117 | if err != nil && err != gredis.Nil { 118 | return []gredis.Z{}, err 119 | } 120 | 121 | return res, nil 122 | } 123 | 124 | func (r *redis) ZRevRangeByScoreWithScoresByOffset(ctx context.Context, 125 | key string, start, stop, offset, count int64) ([]gredis.Z, error) { 126 | 127 | startStr, stopStr := strconv.FormatInt(start, 10), strconv.FormatInt(stop, 10) 128 | res, err := r.client.ZRevRangeByScoreWithScores(ctx, 129 | key, &gredis.ZRangeBy{Min: startStr, Max: stopStr, Offset: offset, Count: count}).Result() 130 | if err != nil && err != gredis.Nil { 131 | return []gredis.Z{}, err 132 | } 133 | 134 | return res, nil 135 | } 136 | 137 | func (r *redis) ZCard(ctx context.Context, key string) (int64, error) { 138 | return r.client.ZCard(ctx, key).Result() 139 | } 140 | 141 | func (r *redis) ZScore(ctx context.Context, key string, member string) (float64, error) { 142 | return r.client.ZScore(ctx, key, member).Result() 143 | } 144 | 145 | func (r *redis) ZAdd(ctx context.Context, key string, members ...Z) (int64, error) { 146 | if len(members) == 0 { 147 | return 0, nil 148 | } 149 | 150 | addSli := make([]*gredis.Z, 0, len(members)) 151 | for _, member := range members { 152 | m := gredis.Z(member) 153 | addSli = append(addSli, &m) 154 | } 155 | 156 | return r.client.ZAdd(ctx, key, addSli...).Result() 157 | } 158 | 159 | func (r *redis) ZCount(ctx context.Context, key string, start, stop int64) (int64, error) { 160 | startStr, stopStr := strconv.FormatInt(start, 10), strconv.FormatInt(stop, 10) 161 | return r.client.ZCount(ctx, key, startStr, stopStr).Result() 162 | } 163 | 164 | func (r *redis) ZRem(ctx context.Context, key string, members ...interface{}) (int64, error) { 165 | return r.client.ZRem(ctx, key, members...).Result() 166 | } 167 | 168 | func (r *redis) ZRemRangeByRank(ctx context.Context, key string, start, stop int64) (int64, error) { 169 | return r.client.ZRemRangeByRank(ctx, key, start, stop).Result() 170 | } 171 | -------------------------------------------------------------------------------- /dispatch/dispatch.go: -------------------------------------------------------------------------------- 1 | package dispatch 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/bucket" 5 | "github.com/changsongl/delay-queue/job" 6 | "github.com/changsongl/delay-queue/pkg/log" 7 | "github.com/changsongl/delay-queue/pool" 8 | "github.com/changsongl/delay-queue/queue" 9 | "github.com/changsongl/delay-queue/timer" 10 | "github.com/prometheus/client_golang/prometheus" 11 | "os" 12 | "os/signal" 13 | "syscall" 14 | "time" 15 | ) 16 | 17 | // Dispatch interface for main stream of the program 18 | type Dispatch interface { 19 | Add(topic job.Topic, id job.ID, delay job.Delay, ttr job.TTR, body job.Body, override bool) (err error) 20 | Pop(topic job.Topic, blockTime time.Duration) (j *job.Job, err error) 21 | Finish(topic job.Topic, id job.ID) (err error) 22 | Delete(topic job.Topic, id job.ID) (err error) 23 | 24 | Run() 25 | } 26 | 27 | type dispatch struct { 28 | logger log.Logger 29 | bucket bucket.Bucket 30 | pool pool.Pool 31 | queue queue.Queue 32 | timer timer.Timer 33 | jobDelayHistogram *prometheus.HistogramVec 34 | } 35 | 36 | // NewDispatch create a new dispatch to run the program 37 | func NewDispatch(logger log.Logger, new func() (bucket.Bucket, pool.Pool, queue.Queue, timer.Timer)) Dispatch { 38 | b, p, q, t := new() 39 | 40 | d := &dispatch{ 41 | logger: logger.WithModule("dispatch"), 42 | bucket: b, 43 | pool: p, 44 | queue: q, 45 | timer: t, 46 | } 47 | 48 | d.initMetrics() 49 | return d 50 | } 51 | 52 | // Run the dispatch with timer for getting ready jobs 53 | func (d *dispatch) Run() { 54 | buckets := d.bucket.GetBuckets() 55 | 56 | for _, b := range buckets { 57 | d.addTask(b) 58 | } 59 | 60 | go func() { 61 | term := make(chan os.Signal, 1) 62 | signal.Notify(term, os.Interrupt, os.Kill, syscall.SIGTERM, syscall.SIGQUIT) 63 | for { 64 | select { 65 | case <-term: 66 | d.logger.Info("Signal dispatch stop") 67 | d.timer.Close() 68 | return 69 | } 70 | } 71 | }() 72 | 73 | d.logger.Info("Run dispatch") 74 | d.timer.Run() 75 | d.logger.Info("Dispatch is stopped") 76 | } 77 | 78 | // addTask the task is to get ready jobs from bucket and check data is valid, 79 | // if yes then push to ready queue, if not then discard. 80 | func (d *dispatch) addTask(bid uint64) { 81 | d.timer.AddTask(func() (bool, error) { 82 | nameVersions, err := d.bucket.GetBucketJobs(bid) 83 | if err != nil { 84 | d.logger.Error("timer.task bucket.GetBucketJobs failed", log.String("err", err.Error())) 85 | return false, err 86 | } 87 | 88 | for _, nameVersion := range nameVersions { 89 | d.logger.Debug("process", log.String("nameVersion", string(nameVersion))) 90 | topic, id, version, err := nameVersion.Parse() 91 | if err != nil { 92 | d.logger.Error("timer.task nameVersion.Parse failed", 93 | log.String("err", err.Error()), log.String("nameVersion", string(nameVersion))) 94 | continue 95 | } 96 | 97 | j, err := d.pool.LoadReadyJob(topic, id, version) 98 | if err != nil { 99 | d.logger.Error("timer.task pool.LoadReadyJob failed", 100 | log.String("err", err.Error()), log.String("topic", string(topic)), 101 | log.String("id", string(id)), log.String("version", version.String())) 102 | continue 103 | } 104 | 105 | err = d.queue.Push(j) 106 | if err != nil { 107 | d.logger.Error("timer.task queue.Push failed", log.String("err", err.Error())) 108 | } 109 | } 110 | 111 | return len(nameVersions) == int(d.bucket.GetMaxFetchNum()), nil 112 | }) 113 | } 114 | 115 | // Add job to job pool and push to bucket. 116 | func (d *dispatch) Add(topic job.Topic, id job.ID, 117 | delay job.Delay, ttr job.TTR, body job.Body, override bool) (err error) { 118 | 119 | j, err := d.pool.CreateJob(topic, id, delay, ttr, body, override) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | err = d.bucket.CreateJob(j, false) 125 | 126 | if err == nil { 127 | d.observeJobDelayTime(j) 128 | } 129 | 130 | return err 131 | } 132 | 133 | // Pop job from bucket and return job info to let user process. if the ttr time 134 | // is not zero, it will requeue after ttr time. if user doesn't call finish before 135 | // that time, then this job can be pop again. User need to make sure ttr time is 136 | // reasonable. 137 | func (d *dispatch) Pop(topic job.Topic, blockTime time.Duration) (j *job.Job, err error) { 138 | var nameVersion job.NameVersion 139 | 140 | if blockTime == 0 { 141 | // find job from ready queue 142 | nameVersion, err = d.queue.Pop(topic) 143 | } else { 144 | nameVersion, err = d.queue.PopWithBlockTime(topic, blockTime) 145 | } 146 | 147 | if err != nil { 148 | return 149 | } else if nameVersion == "" { 150 | err = nil 151 | return 152 | } 153 | 154 | topic, id, version, err := nameVersion.Parse() 155 | if err != nil { 156 | return 157 | } 158 | 159 | j, err = d.pool.LoadReadyJob(topic, id, version) 160 | if err != nil { 161 | return 162 | } 163 | 164 | if j.TTR != 0 { 165 | err := d.bucket.CreateJob(j, true) 166 | if err != nil { 167 | d.logger.Error("bucket ttr requeue failed", log.String("err", err.Error())) 168 | } 169 | } 170 | 171 | return j, nil 172 | } 173 | 174 | // Finish job. ack the processed job after user has done their job. 175 | // delay queue will stop retrying and delete all information. 176 | func (d *dispatch) Finish(topic job.Topic, id job.ID) (err error) { 177 | // set it is done 178 | return d.pool.DeleteJob(topic, id) 179 | } 180 | 181 | // Delete job before. only delete job, when the bucket event is trigger, 182 | // it gonna find the job is deleted, so it won't push to the ready queue. 183 | func (d *dispatch) Delete(topic job.Topic, id job.ID) (err error) { 184 | // delete job 185 | return d.pool.DeleteJob(topic, id) 186 | } 187 | 188 | // initMetrics init all prometheus metrics 189 | func (d *dispatch) initMetrics() { 190 | d.jobDelayHistogram = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 191 | Name: "delay_queue_job_delay_time", 192 | Help: "Histogram of the delay time of the job (seconds)", 193 | Buckets: []float64{ 194 | float64(time.Minute / time.Second), // 1 minute 195 | float64(time.Hour / time.Second), // 1 hour 196 | float64((24 * time.Hour) / time.Second), // 1 day 197 | float64((30 * 24 * time.Hour) / time.Second), // 1 month (30 days) 198 | float64((365 * 24 * time.Hour) / time.Second), // 1 year (365 days) 199 | }, 200 | }, []string{"topic"}) 201 | 202 | if err := prometheus.Register(d.jobDelayHistogram); err != nil { 203 | d.logger.Error("prometheus.Register d.jobDelayHistogram failed", log.Error(err)) 204 | } 205 | } 206 | 207 | // observeJobDelayTime observe the job delay time to prometheus 208 | func (d *dispatch) observeJobDelayTime(job *job.Job) { 209 | d.jobDelayHistogram.WithLabelValues(string(job.Topic)).Observe(float64(time.Duration(job.Delay) / time.Second)) 210 | } 211 | -------------------------------------------------------------------------------- /pkg/encode/encode_test.go: -------------------------------------------------------------------------------- 1 | package encode 2 | 3 | import ( 4 | "github.com/changsongl/delay-queue/job" 5 | "github.com/changsongl/delay-queue/pkg/lock" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | var longText = ` 11 | The Bible is not a single book but a collection of books, whose complex development is not completely understood. The books began as songs and stories orally transmitted from generation to generation before being written down in a process that began sometime around the start of the first millennium BCE and continued for over a thousand years. The Bible was written and compiled by many people, from a variety of disparate cultures, most of whom are unknown.[18] British biblical scholar John K. Riches wrote:[19] 12 | [T]he biblical texts were produced over a period in which the living conditions of the writers – political, cultural, economic, and ecological – varied enormously. There are texts which reflect a nomadic existence, texts from people with an established monarchy and Temple cult, texts from exile, texts born out of fierce oppression by foreign rulers, courtly texts, texts from wandering charismatic preachers, texts from those who give themselves the airs of sophisticated Hellenistic writers. It is a time-span which encompasses the compositions of Homer, Plato, Aristotle, Thucydides, Sophocles, Caesar, Cicero, and Catullus. It is a period which sees the rise and fall of the Assyrian empire (twelfth to seventh century) and of the Persian empire (sixth to fourth century), Alexander's campaigns (336–326), the rise of Rome and its domination of the Mediterranean (fourth century to the founding of the Principate, 27 BCE), the destruction of the Jerusalem Temple (70 CE), and the extension of Roman rule to parts of Scotland (84 CE). 13 | Hebrew Bible from 1300. page 20, Genesis. 14 | Hebrew Bible from 1300. Genesis. 15 | Considered to be scriptures (sacred, authoritative religious texts), the books were compiled by different religious communities into various biblical canons (official collections of scriptures). The earliest compilation, containing the first five books of the Bible and called the Torah (meaning "law", "instruction", or "teaching") or Pentateuch ("five books"), was accepted as Jewish canon by the 5th century BCE. A second collection of narrative histories and prophesies, called the Nevi'im ("prophets"), was canonized in the 3rd century BCE. A third collection called the Ketuvim ("writings"), containing psalms, proverbs, and narrative histories, was canonized sometime between the 2nd century BCE and the 2nd century CE. These three collections were written mostly in Hebrew, with some parts in Aramaic, and together form the Hebrew Bible or "TaNaKh" (a portmanteau of "Torah", "Nevi'im", and "Ketuvim").[20] 16 | Greek-speaking Jews in Alexandria and elsewhere in the Jewish diaspora considered additional scriptures, composed between 200 BCE and 100 CE and not included in the Hebrew Bible, to be canon. These additional texts were included in a translation of the Hebrew Bible into Koine Greek (common Greek spoken by ordinary people) known as the Septuagint (meaning "the work of the seventy"), which began as a translation of the Torah made around 250 BCE and continued to develop for several centuries. The Septuagint contained all of the books of the Hebrew Bible, re-organized and with some textual differences, with the additional scriptures interspersed throughout.[21] 17 | Saint Paul Writing His Epistles, 16th-century painting. 18 | During the rise of Christianity in the 1st century CE, new scriptures were written in Greek about the life and teachings of Jesus Christ, who Christians believed was the messiah prophesized in the books of the Hebrew Bible. Two collections of these new scriptures – the Pauline epistles and the Gospels – were accepted as canon by the end of the 2nd century CE. A third collection, the catholic epistles, were canonized over the next few centuries. Christians called these new scriptures the "New Testament", and began referring to the Septuagint as the "Old Testament".[22] 19 | Between 385 and 405 CE, the early Christian church translated its canon into Vulgar Latin (the common Latin spoken by ordinary people), a translation known as the Vulgate, which included in its Old Testament the books that were in the Septuagint but not in the Hebrew Bible. The Vulgate introduced stability to the Bible, but also began the East-West Schism between Latin-speaking Western Christianity (led by the Catholic Church) and multi-lingual Eastern Christianity (led by the Eastern Orthodox Church). Christian denominations' biblical canons varied not only in the language of the books, but also in their selection, organization, and text.[23] 20 | Jewish rabbis began developing a standard Hebrew Bible in the 1st century CE, maintained since the middle of the first millennium by the Masoretes, and called the Masoretic Text. Christians have held ecumenical councils to standardize their biblical canon since the 4th century CE. The Council of Trent (1545–63), held by the Catholic Church in response to the Protestant Reformation, authorized the Vulgate as its official Latin translation of the Bible. The Church deemed the additional books in its Old Testament that were interspersed among the Hebrew Bible books to be "deuterocanonical" (meaning part of a second or later canon). Protestant Bibles either separated these books into a separate section called the "Apocrypha" (meaning "hidden away") between the Old and New Testaments, or omitted them altogether. The 17th-century Protestant King James Version was the most ubiquitous English Bible of all time, but it has largely been superseded by modern translations.[24]` 21 | 22 | func runEncodeTest(t *testing.T, encoder Encoder) { 23 | for _, j := range testCases(t) { 24 | str, err := encoder.Encode(j) 25 | require.NoError(t, err) 26 | 27 | jDecode := &job.Job{} 28 | err = encoder.Decode(str, jDecode) 29 | t.Logf("%+v", jDecode) 30 | 31 | require.NoError(t, err) 32 | require.Equal(t, j.ID, jDecode.ID) 33 | require.Equal(t, j.TTR, jDecode.TTR) 34 | require.Equal(t, j.Delay, jDecode.Delay) 35 | require.Equal(t, j.Topic, jDecode.Topic) 36 | require.True(t, j.Version.Equal(jDecode.Version)) 37 | require.Equal(t, j.Body, jDecode.Body) 38 | require.Equal(t, j.Version.String(), jDecode.Version.String()) 39 | } 40 | } 41 | 42 | func testCases(t *testing.T) []*job.Job { 43 | jEmpty, err := job.New("jobTopic", "1sdsa", 0, 0, "", func(name string) lock.Locker { 44 | return nil 45 | }) 46 | require.NoError(t, err) 47 | 48 | jAllEmpty := &job.Job{} 49 | 50 | jShort, err := job.New("jobTopicdsad", "1sdsadsads", 10, 51 | 20, "gfdgsdas", func(name string) lock.Locker { 52 | return nil 53 | }, 54 | ) 55 | require.NoError(t, err) 56 | 57 | jLong, err := job.New(job.Topic(longText), job.ID(longText), 10000000000, 58 | 10000000000, job.Body(longText), func(name string) lock.Locker { 59 | return nil 60 | }, 61 | ) 62 | require.NoError(t, err) 63 | 64 | return []*job.Job{ 65 | jEmpty, 66 | jAllEmpty, 67 | jShort, 68 | jLong, 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | encodeJson "encoding/json" 5 | "errors" 6 | "github.com/changsongl/delay-queue/config/decode" 7 | "github.com/changsongl/delay-queue/config/decode/json" 8 | "github.com/changsongl/delay-queue/config/decode/yaml" 9 | "io/ioutil" 10 | "os" 11 | ) 12 | 13 | // FileType configuration file type 14 | type FileType string 15 | 16 | // config file type enum 17 | const ( 18 | // FileTypeYAML yaml type 19 | FileTypeYAML FileType = "yaml" 20 | // FileTypeJSON json type 21 | FileTypeJSON FileType = "json" 22 | ) 23 | 24 | // RedisMode redis set up 25 | type RedisMode string 26 | 27 | const ( 28 | // RedisModeNormal redis normal mode 29 | RedisModeNormal RedisMode = "" 30 | // RedisModeCluster redis cluster 31 | RedisModeCluster RedisMode = "cluster" 32 | ) 33 | 34 | // default configurations 35 | const ( 36 | // delay queue configuration 37 | DefaultDQBindAddress = ":8000" 38 | DefaultDQBucketName = "dq_bucket" 39 | DefaultDQQueueName = "dq_queue" 40 | DefaultDQBucketSize = 8 41 | DefaultDQBucketMaxFetchNum = 200 42 | DefaultTimerFetchInterval = 10000 43 | DefaultFetchJobBlockTime = 5 44 | 45 | // redis configuration 46 | DefaultRedisNetwork = "tcp" 47 | DefaultRedisAddress = "127.0.0.1:6379" 48 | DefaultRedisDialTimeout = 5000 49 | DefaultRedisReadTimeout = 3000 50 | DefaultRedisWriteTimeout = 3000 51 | ) 52 | 53 | // Conf configuration 54 | type Conf struct { 55 | DelayQueue DelayQueue `yaml:"delay_queue,omitempty" json:"delay_queue,omitempty"` 56 | Redis Redis `yaml:"redis,omitempty" json:"redis,omitempty"` 57 | } 58 | 59 | // TODO: add duration to configs 60 | 61 | // DelayQueue delay queue configuration 62 | type DelayQueue struct { 63 | // listen address 64 | BindAddress string `yaml:"bind_address,omitempty" json:"bind_address,omitempty"` 65 | // bucket redis key name 66 | BucketName string `yaml:"bucket_name,omitempty" json:"bucket_name,omitempty"` 67 | // the number of delay queue bucket, increase number could get better concurrency. 68 | BucketSize uint64 `yaml:"bucket_size,omitempty" json:"bucket_size,omitempty"` 69 | // max fetch number of jobs in the bucket 70 | BucketMaxFetchNum uint64 `yaml:"bucket_max_fetch_num,omitempty" json:"bucket_max_fetch_num,omitempty"` 71 | // queue redis key name 72 | QueueName string `yaml:"queue_name,omitempty" json:"queue_name,omitempty"` 73 | // fetching job interval(ms), decrease interval may get better throughout. 74 | TimerFetchInterval int `yaml:"timer_fetch_interval,omitempty" json:"timer_fetch_interval,omitempty"` 75 | // fetch delay(ms), if there are still job in the bucket after the fetch 76 | // it will delay timer_fetch_delay ms for next fetch. Default is not wait. 77 | TimerFetchDelay int `yaml:"timer_fetch_delay,omitempty" json:"timer_fetch_delay,omitempty"` 78 | } 79 | 80 | // Redis redis configuration 81 | type Redis struct { 82 | // Redis set up, "" for regular redis, "cluster" for redis cluster 83 | Mode RedisMode `yaml:"mode,omitempty" json:"mode,omitempty"` 84 | // The network type, either tcp or unix. 85 | // Default is tcp. 86 | Network string `yaml:"network,omitempty" json:"network,omitempty"` 87 | 88 | // host:port address. 89 | Address string `yaml:"address,omitempty" json:"address,omitempty"` 90 | 91 | // Use the specified username to authenticate the current connection 92 | // with one of the connections defined in the ACL list when connecting 93 | // to a Redis 6.0 instance, or greater, that is using the Redis ACL system. 94 | Username string `yaml:"username,omitempty" json:"username,omitempty"` 95 | 96 | // Optional password. Must match the password specified in the 97 | // require pass server configuration option (if connecting to a Redis 5.0 instance, or lower), 98 | // or the User password when connecting to a Redis 6.0 instance, or greater, 99 | // that is using the Redis ACL system. 100 | Password string `yaml:"password,omitempty" json:"password,omitempty"` 101 | 102 | // Database to be selected after connecting to the server. 103 | DB int `yaml:"db,omitempty" json:"db,omitempty"` 104 | 105 | // Dial timeout for establishing new connections. 106 | // Default is 5 seconds. 107 | DialTimeout int `yaml:"dial_timeout,omitempty" json:"dial_timeout,omitempty"` 108 | // Timeout for socket reads. If reached, commands will fail 109 | // with a timeout instead of blocking. Use value -1 for no timeout and 0 for default. 110 | // Default is 3 seconds. 111 | ReadTimeout int `yaml:"read_timeout,omitempty" json:"read_timeout,omitempty"` 112 | // Timeout for socket writes. If reached, commands will fail 113 | // with a timeout instead of blocking. 114 | // Default is ReadTimeout. 115 | WriteTimeout int `yaml:"write_timeout,omitempty" json:"write_timeout,omitempty"` 116 | 117 | // Maximum number of socket connections. 118 | // Default is 10 connections per every CPU as reported by runtime.NumCPU. 119 | PoolSize int `yaml:"pool_size,omitempty" json:"pool_size,omitempty"` 120 | // Minimum number of idle connections which is useful when establishing 121 | // new connection is slow. 122 | MinIdleConns int `yaml:"min_idle_conns,omitempty" json:"min_idle_conns,omitempty"` 123 | } 124 | 125 | // New Conf instance 126 | func New() *Conf { 127 | return &Conf{ 128 | DelayQueue: DelayQueue{ 129 | BindAddress: DefaultDQBindAddress, 130 | BucketName: DefaultDQBucketName, 131 | BucketSize: DefaultDQBucketSize, 132 | QueueName: DefaultDQQueueName, 133 | BucketMaxFetchNum: DefaultDQBucketMaxFetchNum, 134 | TimerFetchInterval: DefaultTimerFetchInterval, 135 | }, 136 | Redis: Redis{ 137 | Network: DefaultRedisNetwork, 138 | Address: DefaultRedisAddress, 139 | DialTimeout: DefaultRedisDialTimeout, 140 | ReadTimeout: DefaultRedisReadTimeout, 141 | WriteTimeout: DefaultRedisWriteTimeout, 142 | }, 143 | } 144 | } 145 | 146 | // Load configuration 147 | func (c *Conf) Load(file string, fileType FileType) error { 148 | f, err := os.Open(file) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | bts, err := ioutil.ReadAll(f) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | decoder, err := c.getDecoderByFileType(fileType) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | if err = c.load(bts, decoder.DecodeFunc()); err != nil { 164 | return err 165 | } 166 | 167 | return nil 168 | } 169 | 170 | // load the real method 171 | func (c *Conf) load(bts []byte, decodeFunc func([]byte, interface{}) error) error { 172 | err := decodeFunc(bts, c) 173 | if err != nil { 174 | return nil 175 | } 176 | return nil 177 | } 178 | 179 | // getDecoderByFileType get file type for decoding 180 | func (c *Conf) getDecoderByFileType(fileType FileType) (decode.Decoder, error) { 181 | if fileType == FileTypeJSON { 182 | return json.NewDecoder(), nil 183 | } else if fileType == FileTypeYAML { 184 | return yaml.NewDecoder(), nil 185 | } 186 | 187 | return nil, errors.New("invalid file type") 188 | } 189 | 190 | // String config string 191 | func (c *Conf) String() string { 192 | bytes, _ := encodeJson.Marshal(c) 193 | return string(bytes) 194 | } 195 | 196 | // IsCluster return current redis mode is cluster 197 | func (r RedisMode) IsCluster() bool { 198 | return r == RedisModeCluster 199 | } 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # delay-queue 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/changsongl/delay-queue)](https://goreportcard.com/report/github.com/changsongl/delay-queue) 4 | [![Build Status](https://travis-ci.com/changsongl/delay-queue.svg?branch=main)](https://travis-ci.com/changsongl/delay-queue) 5 | [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fchangsongl%2Fdelay-queue&count_bg=%232BBC8A&title_bg=%23555555&icon=artstation.svg&icon_color=%23C7C7C7&title=Visitor&edge_flat=false)](https://hits.seeyoufarm.com) 6 | 7 | Translations: 8 | 9 | - [中文文档](./README_ZH.md) 10 | 11 | ```` 12 | ____ _ ___ 13 | | _ \ ___| | __ _ _ _ / _ \ _ _ ___ _ _ ___ 14 | | | | |/ _ \ |/ _` | | | | | | | | | | |/ _ \ | | |/ _ \ 15 | | |_| | __/ | (_| | |_| | | |_| | |_| | __/ |_| | __/ 16 | |____/ \___|_|\__,_|\__, | \__\_\\__,_|\___|\__,_|\___| 17 | |___/ 18 | ```` 19 | 20 | ### Introduction 21 | This project is a delay queue. It is based on Youzan 有赞 delay queue. Currently, 22 | it is based on Redis for storage. 23 | 24 | ### Highly Available 25 | ![arch](./doc/arch/Delay-Queue-HA.jpg) 26 | 27 | ### How to run the delay queue? 28 | ````shell 29 | # clone project 30 | git clone https://github.com/changsongl/delay-queue.git 31 | 32 | # build the project 33 | make 34 | 35 | # run the project 36 | bin/delayqueue 37 | ```` 38 | 39 | ````shell 40 | # flags 41 | bin/delayqueue -help 42 | -config.file string 43 | config file (default "../../config/config.yaml") 44 | -config.type string 45 | config type: yaml, json 46 | -env string 47 | delay queue env: debug, release (default "release") 48 | -version 49 | display build info 50 | ```` 51 | 52 | The default configuration file is `config/config.yaml.example`. 53 | 54 | ### Usage 55 | - ##### SDK [Link](https://github.com/changsongl/delay-queue-client) 56 | 57 | - ##### Http 58 | 59 | ```` 60 | // Push job 61 | POST 127.0.0.1:8000/topic/mytopic/job 62 | body: {"id": "myid1","delay":10, "ttr":4, "body":"body"} 63 | 64 | // response 65 | { 66 | "message": "ok", 67 | "success": true 68 | } 69 | ```` 70 | 71 | ```` 72 | // Pop job (timeout: seconds) 73 | GET 127.0.0.1:8000/topic/mytopic/job?timeout=5 74 | 75 | // response 76 | { 77 | "message": "ok", 78 | "success": true, 79 | "data": { 80 | "body": "body", 81 | "delay": 10, 82 | "id": "myid1", 83 | "topic": "mytopic", 84 | "ttr": 4 85 | } 86 | } 87 | ```` 88 | 89 | ```` 90 | // Delete job 91 | DELETE 127.0.0.1:8000/topic/mytopic/job/myid1 92 | 93 | // response 94 | { 95 | "message": "ok", 96 | "success": true 97 | } 98 | ```` 99 | 100 | ```` 101 | // Delete job 102 | PUT 127.0.0.1:8000/topic/mytopic/job/myid1 103 | 104 | // response 105 | { 106 | "message": "ok", 107 | "success": true 108 | } 109 | ```` 110 | 111 | ### Designs 112 | 113 | #### Terms 114 | 1. Job: It is a task to be processed, and it is related to only one topic. 115 | 2. Topic: It is a set of jobs, it is implemented by a time-sorted queue. 116 | All consumers need to choose at least one topic to consume jobs. 117 | 118 | #### Job 119 | Jobs contain many properties like: 120 | 1. Topic: It could be a service name, users can define it depending on their 121 | business. 122 | 2. ID: it is unique key for inside of a topic. It's used to search job information 123 | in a topic. The combination of a topic and an ID should be unique in your 124 | business. 125 | 3. Delay: It defines how many second to be delay for the job. Unit: Second 126 | 4. TTR(time to run): It is job processing timeout. If consumer process this 127 | job more than TTR seconds, it might be sent to other consumer, if a consumer 128 | pop the topic. 129 | 5. Body: It is content of job. It is a string. You can put your json data to it. 130 | When you consume the job, you can decode it and run your logic. 131 | 132 | 133 | #### Component 134 | 135 | >There are 4 components in the delay queue. 136 | >1. Job Pool: It saves all metadata of jobs. 137 | >2. Delay Bucket: It is a time-sorted queue. It saves jobs that is waiting 138 | for being ready. There are more than one Bucket in the delay queue for 139 | higher throughput. 140 | >3. Timer: It is a core component to scan the Delay Bucket. It pops out 141 | ready jobs from Buckets and put then inside ready queue. 142 | >4. Ready Queue: It is a queue for storing all ready jobs, which can be 143 | popped now. It is also only store the job id for the consumers. 144 | 145 | delay-queue 146 | 147 | #### States 148 | >There are four states for jobs in the delay queue. The job can be only 149 | > in one state at the time. 150 | >1. Ready: It is ready to be consumed. 151 | >2. Delay: It is waiting for the delay time, and it can't be consumed. 152 | >3. reserved: It means the job has consumed by a consumer, but consumer 153 | > hasn't ack the job. (Call delete、finish). 154 | >4. Deleted: The job has finished or deleted. 155 | 156 | job-state 157 | 158 | ### Monitor 159 | This project is using Prometheus as the monitor tool. It exposes the metrics apis to Prometheus. 160 | You can use Prometheus and Grafana as the monitor tools. 161 | 162 | ```` 163 | # HELP delay_queue_in_flight_jobs_numbers_in_bucket Gauge of the number of inflight jobs in each bucket 164 | # TYPE delay_queue_in_flight_jobs_numbers_in_bucket gauge 165 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_0"} 0 166 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_1"} 3 167 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_2"} 0 168 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_3"} 0 169 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_4"} 0 170 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_5"} 0 171 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_6"} 0 172 | delay_queue_in_flight_jobs_numbers_in_bucket{bucket="dq_bucket_7"} 0 173 | . 174 | . 175 | . 176 | # HELP delay_queue_in_flight_jobs_numbers_in_queue Gauge of the number of inflight jobs in each queue 177 | # TYPE delay_queue_in_flight_jobs_numbers_in_queue gauge 178 | delay_queue_in_flight_jobs_numbers_in_queue{queue="dq_queue_mytopic"} 1 179 | ```` 180 | 181 | ### What's the plan of this project? 182 | I will work on this project all the time! I will add more features and 183 | fix bugs, and I will make this project ready to use in production. Star 184 | Or Fork it if you like it. I'm very welcome to you for contribution. 185 | 186 | ### How to contribute? 187 | 1. Level a message in the unsigned issue. 188 | 2. We will discuss how to do it, and I will assign the issue to you. 189 | 3. Fork the project, and checkout your branch from "develop" branch. 190 | 4. Submit the PR to "develop" branch. 191 | 5. It will be merged after code review. 192 | 193 | ### Stargazers 194 | [![Stargazers over time](https://starchart.cc/changsongl/delay-queue.svg)](https://starchart.cc/changsongl/delay-queue) 195 | 196 | ### Reference 197 | 198 | Youzan Design Concept [Youzan Link](https://tech.youzan.com/queuing_delay/) 199 | -------------------------------------------------------------------------------- /pkg/redis/redis.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "context" 5 | "github.com/changsongl/delay-queue/config" 6 | "github.com/changsongl/delay-queue/pkg/lock" 7 | gredis "github.com/go-redis/redis/v8" 8 | "github.com/go-redsync/redsync/v4" 9 | "github.com/go-redsync/redsync/v4/redis/goredis/v8" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | // TODO: add pipeline 15 | 16 | // Redis interface 17 | type Redis interface { 18 | Del(ctx context.Context, key string) (bool, error) 19 | Exists(ctx context.Context, key string) (bool, error) 20 | Expire(ctx context.Context, key string, expiration time.Duration) (bool, error) 21 | ExpireAt(ctx context.Context, key string, tm time.Time) (bool, error) 22 | TTL(ctx context.Context, key string) (time.Duration, error) 23 | FlushDB(ctx context.Context) error 24 | 25 | // kv 26 | Get(ctx context.Context, key string) (string, error) 27 | Set(ctx context.Context, key string, value interface{}) error 28 | SetEx(ctx context.Context, key string, value interface{}, expire time.Duration) error 29 | Incr(ctx context.Context, key string) (int64, error) 30 | IncrBy(ctx context.Context, key string, increment int64) (int64, error) 31 | Decr(ctx context.Context, key string) (int64, error) 32 | DecrBy(ctx context.Context, key string, decrement int64) (int64, error) 33 | MGet(ctx context.Context, keys ...string) ([]*string, error) 34 | MSet(ctx context.Context, kvs map[string]interface{}) error 35 | SetNx(ctx context.Context, key string, value interface{}) (bool, error) 36 | SetNxExpire(ctx context.Context, key string, value interface{}, expiration time.Duration) (bool, error) 37 | 38 | // hash 39 | HGetAll(ctx context.Context, key string) (map[string]string, error) 40 | HMGet(ctx context.Context, key string, fields []string) ([]*string, error) 41 | HGet(ctx context.Context, key string, field string) (string, error) 42 | HMSet(ctx context.Context, key string, hash map[string]interface{}) (bool, error) 43 | HSet(ctx context.Context, key string, field string, value interface{}) (overwrite bool, err error) 44 | HSetNX(ctx context.Context, key string, field string, value interface{}) (overwrite bool, err error) 45 | HDel(ctx context.Context, key string, fields ...string) (delNum int64, err error) 46 | HExists(ctx context.Context, key string, field string) (exists bool, err error) 47 | HKeys(ctx context.Context, key string) ([]string, error) 48 | HLen(ctx context.Context, key string) (int64, error) 49 | HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error) 50 | HIncrByFloat(ctx context.Context, key string, field string, incr float64) (float64, error) 51 | 52 | // zset 53 | ZRevRange(ctx context.Context, key string, start, stop int64) ([]string, error) 54 | ZRevRangeWithScores(ctx context.Context, key string, start, stop int64) ([]Z, error) 55 | ZRange(ctx context.Context, key string, start, stop int64) ([]string, error) 56 | ZRangeWithScores(ctx context.Context, key string, start, stop int64) ([]Z, error) 57 | ZRangeByScoreWithScores(ctx context.Context, key string, start, stop int64) ([]Z, error) 58 | ZRangeByScoreWithScoresByOffset(ctx context.Context, key string, start, stop, offset, count int64) ([]Z, error) 59 | ZRangeByScore(ctx context.Context, key string, start, stop int64) ([]string, error) 60 | ZRangeByScoreByOffset(ctx context.Context, key string, start, stop, offset, count int64) ([]string, error) 61 | ZRevRank(ctx context.Context, key string, member string) (int64, error) 62 | ZRevRangeByScore(ctx context.Context, key string, start, stop int64) ([]string, error) 63 | ZRevRangeByScoreByOffset(ctx context.Context, key string, start, stop, offset, count int64) ([]string, error) 64 | ZRevRangeByScoreWithScores(ctx context.Context, key string, start, stop int64) ([]gredis.Z, error) 65 | ZRevRangeByScoreWithScoresByOffset(ctx context.Context, key string, start, stop, offset, count int64) ([]gredis.Z, error) 66 | ZCard(ctx context.Context, key string) (int64, error) 67 | ZScore(ctx context.Context, key string, member string) (float64, error) 68 | ZAdd(ctx context.Context, key string, members ...Z) (int64, error) 69 | ZCount(ctx context.Context, key string, start, stop int64) (int64, error) 70 | ZRem(ctx context.Context, key string, members ...interface{}) (int64, error) 71 | ZRemRangeByRank(ctx context.Context, key string, start, stop int64) (int64, error) 72 | 73 | // list 74 | BRPop(ctx context.Context, key string, blockTime time.Duration) ([]string, error) 75 | LPush(ctx context.Context, key string, values ...interface{}) (int64, error) 76 | RPush(ctx context.Context, key string, values ...interface{}) (int64, error) 77 | RPop(ctx context.Context, key string) (string, error) 78 | LPop(ctx context.Context, key string) (string, error) 79 | LRange(ctx context.Context, key string, start, stop int64) ([]string, error) 80 | LLen(ctx context.Context, key string) (int64, error) 81 | LRem(ctx context.Context, key string, count int64, value interface{}) (int64, error) 82 | LIndex(ctx context.Context, key string, idx int64) (string, error) 83 | LTrim(ctx context.Context, key string, start, stop int64) (string, error) 84 | 85 | // set 86 | SAdd(ctx context.Context, key string, member ...interface{}) (int64, error) 87 | SMembers(ctx context.Context, key string) ([]string, error) 88 | SRem(ctx context.Context, key string, member ...interface{}) (int64, error) 89 | SPop(ctx context.Context, key string) (string, error) 90 | SRandMemberN(ctx context.Context, key string, count int64) ([]string, error) 91 | SRandMember(ctx context.Context, key string) (string, error) 92 | SCard(ctx context.Context, key string) (int64, error) 93 | SIsMember(ctx context.Context, key string, member interface{}) (bool, error) 94 | 95 | GetLocker(name string) lock.Locker 96 | 97 | Close() (err error) 98 | } 99 | 100 | type redis struct { 101 | client gredis.Cmdable 102 | closeFunc func() error 103 | sync *redsync.Redsync 104 | isCluster bool 105 | } 106 | 107 | // New create a redis 108 | func New(conf config.Redis) Redis { 109 | // check if it is cluster 110 | if conf.Mode.IsCluster() { 111 | return newClusterRedis(conf) 112 | } 113 | 114 | return newSingleRedis(conf) 115 | } 116 | 117 | // new cluster redis 118 | func newClusterRedis(conf config.Redis) Redis { 119 | addresses := isClusterInstance(conf.Address) 120 | cli := gredis.NewClusterClient( 121 | &gredis.ClusterOptions{ 122 | Addrs: addresses, 123 | Username: conf.Username, 124 | Password: conf.Password, 125 | DialTimeout: time.Duration(conf.DialTimeout) * time.Millisecond, 126 | ReadTimeout: time.Duration(conf.ReadTimeout) * time.Millisecond, 127 | WriteTimeout: time.Duration(conf.WriteTimeout) * time.Millisecond, 128 | PoolSize: conf.PoolSize, 129 | MinIdleConns: conf.MinIdleConns, 130 | }, 131 | ) 132 | rs := redsync.New(goredis.NewPool(cli)) 133 | 134 | return &redis{ 135 | client: cli, 136 | sync: rs, 137 | closeFunc: cli.Close, 138 | } 139 | } 140 | 141 | // new single redis 142 | func newSingleRedis(conf config.Redis) Redis { 143 | cli := gredis.NewClient( 144 | &gredis.Options{ 145 | Network: conf.Network, 146 | Addr: conf.Address, 147 | Username: conf.Username, 148 | Password: conf.Password, 149 | DB: conf.DB, 150 | DialTimeout: time.Duration(conf.DialTimeout) * time.Millisecond, 151 | ReadTimeout: time.Duration(conf.ReadTimeout) * time.Millisecond, 152 | WriteTimeout: time.Duration(conf.WriteTimeout) * time.Millisecond, 153 | PoolSize: conf.PoolSize, 154 | MinIdleConns: conf.MinIdleConns, 155 | }, 156 | ) 157 | 158 | rs := redsync.New(goredis.NewPool(cli)) 159 | 160 | return &redis{ 161 | client: cli, 162 | sync: rs, 163 | closeFunc: cli.Close, 164 | } 165 | } 166 | 167 | // isClusterInstance is to check if the add is cluster address. Ex: ip1:port1,ip2:port2 168 | func isClusterInstance(addr string) []string { 169 | address := strings.Split(addr, ",") 170 | return address 171 | } 172 | 173 | // Close close client and all connections 174 | func (r *redis) Close() (err error) { 175 | if r.client != nil { 176 | err = r.closeFunc() 177 | } 178 | return 179 | } 180 | -------------------------------------------------------------------------------- /test/mock/store/store.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: store.go 3 | 4 | // Package mock_store is a generated GoMock package. 5 | package mock_store 6 | 7 | import ( 8 | reflect "reflect" 9 | time "time" 10 | 11 | job "github.com/changsongl/delay-queue/job" 12 | lock "github.com/changsongl/delay-queue/pkg/lock" 13 | gomock "github.com/golang/mock/gomock" 14 | ) 15 | 16 | // MockStore is a mock of Store interface. 17 | type MockStore struct { 18 | ctrl *gomock.Controller 19 | recorder *MockStoreMockRecorder 20 | } 21 | 22 | // MockStoreMockRecorder is the mock recorder for MockStore. 23 | type MockStoreMockRecorder struct { 24 | mock *MockStore 25 | } 26 | 27 | // NewMockStore creates a new mock instance. 28 | func NewMockStore(ctrl *gomock.Controller) *MockStore { 29 | mock := &MockStore{ctrl: ctrl} 30 | mock.recorder = &MockStoreMockRecorder{mock} 31 | return mock 32 | } 33 | 34 | // EXPECT returns an object that allows the caller to indicate expected use. 35 | func (m *MockStore) EXPECT() *MockStoreMockRecorder { 36 | return m.recorder 37 | } 38 | 39 | // BPopJobFromQueue mocks base method. 40 | func (m *MockStore) BPopJobFromQueue(queue string, blockTime time.Duration) (job.NameVersion, error) { 41 | m.ctrl.T.Helper() 42 | ret := m.ctrl.Call(m, "BPopJobFromQueue", queue, blockTime) 43 | ret0, _ := ret[0].(job.NameVersion) 44 | ret1, _ := ret[1].(error) 45 | return ret0, ret1 46 | } 47 | 48 | // BPopJobFromQueue indicates an expected call of BPopJobFromQueue. 49 | func (mr *MockStoreMockRecorder) BPopJobFromQueue(queue, blockTime interface{}) *gomock.Call { 50 | mr.mock.ctrl.T.Helper() 51 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BPopJobFromQueue", reflect.TypeOf((*MockStore)(nil).BPopJobFromQueue), queue, blockTime) 52 | } 53 | 54 | // CollectInFlightJobNumberBucket mocks base method. 55 | func (m *MockStore) CollectInFlightJobNumberBucket(bucket string) (uint64, error) { 56 | m.ctrl.T.Helper() 57 | ret := m.ctrl.Call(m, "CollectInFlightJobNumberBucket", bucket) 58 | ret0, _ := ret[0].(uint64) 59 | ret1, _ := ret[1].(error) 60 | return ret0, ret1 61 | } 62 | 63 | // CollectInFlightJobNumberBucket indicates an expected call of CollectInFlightJobNumberBucket. 64 | func (mr *MockStoreMockRecorder) CollectInFlightJobNumberBucket(bucket interface{}) *gomock.Call { 65 | mr.mock.ctrl.T.Helper() 66 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CollectInFlightJobNumberBucket", reflect.TypeOf((*MockStore)(nil).CollectInFlightJobNumberBucket), bucket) 67 | } 68 | 69 | // CollectInFlightJobNumberQueue mocks base method. 70 | func (m *MockStore) CollectInFlightJobNumberQueue(queuePrefix string) (map[string]uint64, error) { 71 | m.ctrl.T.Helper() 72 | ret := m.ctrl.Call(m, "CollectInFlightJobNumberQueue", queuePrefix) 73 | ret0, _ := ret[0].(map[string]uint64) 74 | ret1, _ := ret[1].(error) 75 | return ret0, ret1 76 | } 77 | 78 | // CollectInFlightJobNumberQueue indicates an expected call of CollectInFlightJobNumberQueue. 79 | func (mr *MockStoreMockRecorder) CollectInFlightJobNumberQueue(queuePrefix interface{}) *gomock.Call { 80 | mr.mock.ctrl.T.Helper() 81 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CollectInFlightJobNumberQueue", reflect.TypeOf((*MockStore)(nil).CollectInFlightJobNumberQueue), queuePrefix) 82 | } 83 | 84 | // CreateJob mocks base method. 85 | func (m *MockStore) CreateJob(j *job.Job) error { 86 | m.ctrl.T.Helper() 87 | ret := m.ctrl.Call(m, "CreateJob", j) 88 | ret0, _ := ret[0].(error) 89 | return ret0 90 | } 91 | 92 | // CreateJob indicates an expected call of CreateJob. 93 | func (mr *MockStoreMockRecorder) CreateJob(j interface{}) *gomock.Call { 94 | mr.mock.ctrl.T.Helper() 95 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateJob", reflect.TypeOf((*MockStore)(nil).CreateJob), j) 96 | } 97 | 98 | // CreateJobInBucket mocks base method. 99 | func (m *MockStore) CreateJobInBucket(bucket string, j *job.Job, isTTR bool) error { 100 | m.ctrl.T.Helper() 101 | ret := m.ctrl.Call(m, "CreateJobInBucket", bucket, j, isTTR) 102 | ret0, _ := ret[0].(error) 103 | return ret0 104 | } 105 | 106 | // CreateJobInBucket indicates an expected call of CreateJobInBucket. 107 | func (mr *MockStoreMockRecorder) CreateJobInBucket(bucket, j, isTTR interface{}) *gomock.Call { 108 | mr.mock.ctrl.T.Helper() 109 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateJobInBucket", reflect.TypeOf((*MockStore)(nil).CreateJobInBucket), bucket, j, isTTR) 110 | } 111 | 112 | // DeleteJob mocks base method. 113 | func (m *MockStore) DeleteJob(j *job.Job) (bool, error) { 114 | m.ctrl.T.Helper() 115 | ret := m.ctrl.Call(m, "DeleteJob", j) 116 | ret0, _ := ret[0].(bool) 117 | ret1, _ := ret[1].(error) 118 | return ret0, ret1 119 | } 120 | 121 | // DeleteJob indicates an expected call of DeleteJob. 122 | func (mr *MockStoreMockRecorder) DeleteJob(j interface{}) *gomock.Call { 123 | mr.mock.ctrl.T.Helper() 124 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteJob", reflect.TypeOf((*MockStore)(nil).DeleteJob), j) 125 | } 126 | 127 | // GetLock mocks base method. 128 | func (m *MockStore) GetLock(name string) lock.Locker { 129 | m.ctrl.T.Helper() 130 | ret := m.ctrl.Call(m, "GetLock", name) 131 | ret0, _ := ret[0].(lock.Locker) 132 | return ret0 133 | } 134 | 135 | // GetLock indicates an expected call of GetLock. 136 | func (mr *MockStoreMockRecorder) GetLock(name interface{}) *gomock.Call { 137 | mr.mock.ctrl.T.Helper() 138 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLock", reflect.TypeOf((*MockStore)(nil).GetLock), name) 139 | } 140 | 141 | // GetReadyJobsInBucket mocks base method. 142 | func (m *MockStore) GetReadyJobsInBucket(bucket string, num uint) ([]job.NameVersion, error) { 143 | m.ctrl.T.Helper() 144 | ret := m.ctrl.Call(m, "GetReadyJobsInBucket", bucket, num) 145 | ret0, _ := ret[0].([]job.NameVersion) 146 | ret1, _ := ret[1].(error) 147 | return ret0, ret1 148 | } 149 | 150 | // GetReadyJobsInBucket indicates an expected call of GetReadyJobsInBucket. 151 | func (mr *MockStoreMockRecorder) GetReadyJobsInBucket(bucket, num interface{}) *gomock.Call { 152 | mr.mock.ctrl.T.Helper() 153 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetReadyJobsInBucket", reflect.TypeOf((*MockStore)(nil).GetReadyJobsInBucket), bucket, num) 154 | } 155 | 156 | // LoadJob mocks base method. 157 | func (m *MockStore) LoadJob(j *job.Job) error { 158 | m.ctrl.T.Helper() 159 | ret := m.ctrl.Call(m, "LoadJob", j) 160 | ret0, _ := ret[0].(error) 161 | return ret0 162 | } 163 | 164 | // LoadJob indicates an expected call of LoadJob. 165 | func (mr *MockStoreMockRecorder) LoadJob(j interface{}) *gomock.Call { 166 | mr.mock.ctrl.T.Helper() 167 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadJob", reflect.TypeOf((*MockStore)(nil).LoadJob), j) 168 | } 169 | 170 | // PopJobFromQueue mocks base method. 171 | func (m *MockStore) PopJobFromQueue(queue string) (job.NameVersion, error) { 172 | m.ctrl.T.Helper() 173 | ret := m.ctrl.Call(m, "PopJobFromQueue", queue) 174 | ret0, _ := ret[0].(job.NameVersion) 175 | ret1, _ := ret[1].(error) 176 | return ret0, ret1 177 | } 178 | 179 | // PopJobFromQueue indicates an expected call of PopJobFromQueue. 180 | func (mr *MockStoreMockRecorder) PopJobFromQueue(queue interface{}) *gomock.Call { 181 | mr.mock.ctrl.T.Helper() 182 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PopJobFromQueue", reflect.TypeOf((*MockStore)(nil).PopJobFromQueue), queue) 183 | } 184 | 185 | // PushJobToQueue mocks base method. 186 | func (m *MockStore) PushJobToQueue(queuePrefix, queueName string, j *job.Job) error { 187 | m.ctrl.T.Helper() 188 | ret := m.ctrl.Call(m, "PushJobToQueue", queuePrefix, queueName, j) 189 | ret0, _ := ret[0].(error) 190 | return ret0 191 | } 192 | 193 | // PushJobToQueue indicates an expected call of PushJobToQueue. 194 | func (mr *MockStoreMockRecorder) PushJobToQueue(queuePrefix, queueName, j interface{}) *gomock.Call { 195 | mr.mock.ctrl.T.Helper() 196 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PushJobToQueue", reflect.TypeOf((*MockStore)(nil).PushJobToQueue), queuePrefix, queueName, j) 197 | } 198 | 199 | // ReplaceJob mocks base method. 200 | func (m *MockStore) ReplaceJob(j *job.Job) error { 201 | m.ctrl.T.Helper() 202 | ret := m.ctrl.Call(m, "ReplaceJob", j) 203 | ret0, _ := ret[0].(error) 204 | return ret0 205 | } 206 | 207 | // ReplaceJob indicates an expected call of ReplaceJob. 208 | func (mr *MockStoreMockRecorder) ReplaceJob(j interface{}) *gomock.Call { 209 | mr.mock.ctrl.T.Helper() 210 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReplaceJob", reflect.TypeOf((*MockStore)(nil).ReplaceJob), j) 211 | } 212 | -------------------------------------------------------------------------------- /pkg/log/field_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "go.uber.org/zap" 6 | "go.uber.org/zap/zapcore" 7 | "math" 8 | "net" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | type username string 14 | 15 | func (n username) MarshalLogObject(enc zapcore.ObjectEncoder) error { 16 | enc.AddString("username", string(n)) 17 | return nil 18 | } 19 | 20 | func TestFieldConstructors(t *testing.T) { 21 | // Interface types. 22 | addr := net.ParseIP("1.2.3.4") 23 | name := username("phil") 24 | ints := []int{5, 6} 25 | 26 | // Helpful values for use in constructing pointers to primitives below. 27 | var ( 28 | boolVal = true 29 | complex128Val = complex(0, 0) 30 | complex64Val complex64 = complex(0, 0) 31 | durationVal = time.Second 32 | float64Val = 1.0 33 | float32Val float32 = 1.0 34 | intVal = 1 35 | int64Val int64 = 1 36 | int32Val int32 = 1 37 | int16Val int16 = 1 38 | int8Val int8 = 1 39 | stringVal = "hello" 40 | timeVal = time.Unix(100000, 0) 41 | uintVal uint = 1 42 | uint64Val uint64 = 1 43 | uint32Val uint32 = 1 44 | uint16Val uint16 = 1 45 | uint8Val uint8 = 1 46 | uintptrVal uintptr = 1 47 | ) 48 | 49 | tests := []struct { 50 | name string 51 | field Field 52 | expect Field 53 | }{ 54 | {"Binary", Field{zapField: zap.Field{Key: "k", Type: zapcore.BinaryType, Interface: []byte("ab12")}}, Binary("k", []byte("ab12"))}, 55 | {"Bool", Field{zapField: zap.Field{Key: "k", Type: zapcore.BoolType, Integer: 1}}, Bool("k", true)}, 56 | {"Bool", Field{zapField: zap.Field{Key: "k", Type: zapcore.BoolType, Integer: 1}}, Bool("k", true)}, 57 | {"ByteString", Field{zapField: zap.Field{Key: "k", Type: zapcore.ByteStringType, Interface: []byte("ab12")}}, ByteString("k", []byte("ab12"))}, 58 | {"Complex128", Field{zapField: zap.Field{Key: "k", Type: zapcore.Complex128Type, Interface: 1 + 2i}}, Complex128("k", 1+2i)}, 59 | {"Complex64", Field{zapField: zap.Field{Key: "k", Type: zapcore.Complex64Type, Interface: complex64(1 + 2i)}}, Complex64("k", 1+2i)}, 60 | {"Duration", Field{zapField: zap.Field{Key: "k", Type: zapcore.DurationType, Integer: 1}}, Duration("k", 1)}, 61 | {"Int", Field{zapField: zap.Field{Key: "k", Type: zapcore.Int64Type, Integer: 1}}, Int("k", 1)}, 62 | {"Int64", Field{zapField: zap.Field{Key: "k", Type: zapcore.Int64Type, Integer: 1}}, Int64("k", 1)}, 63 | {"Int32", Field{zapField: zap.Field{Key: "k", Type: zapcore.Int32Type, Integer: 1}}, Int32("k", 1)}, 64 | {"Int16", Field{zapField: zap.Field{Key: "k", Type: zapcore.Int16Type, Integer: 1}}, Int16("k", 1)}, 65 | {"Int8", Field{zapField: zap.Field{Key: "k", Type: zapcore.Int8Type, Integer: 1}}, Int8("k", 1)}, 66 | {"String", Field{zapField: zap.Field{Key: "k", Type: zapcore.StringType, String: "foo"}}, String("k", "foo")}, 67 | {"Time", Field{zapField: zap.Field{Key: "k", Type: zapcore.TimeType, Integer: 0, Interface: time.UTC}}, Time("k", time.Unix(0, 0).In(time.UTC))}, 68 | {"Time", Field{zapField: zap.Field{Key: "k", Type: zapcore.TimeType, Integer: 1000, Interface: time.UTC}}, Time("k", time.Unix(0, 1000).In(time.UTC))}, 69 | {"Time", Field{zapField: zap.Field{Key: "k", Type: zapcore.TimeType, Integer: math.MinInt64, Interface: time.UTC}}, Time("k", time.Unix(0, math.MinInt64).In(time.UTC))}, 70 | {"Time", Field{zapField: zap.Field{Key: "k", Type: zapcore.TimeType, Integer: math.MaxInt64, Interface: time.UTC}}, Time("k", time.Unix(0, math.MaxInt64).In(time.UTC))}, 71 | {"Time", Field{zapField: zap.Field{Key: "k", Type: zapcore.TimeFullType, Interface: time.Time{}}}, Time("k", time.Time{})}, 72 | {"Time", Field{zapField: zap.Field{Key: "k", Type: zapcore.TimeFullType, Interface: time.Unix(math.MaxInt64, 0)}}, Time("k", time.Unix(math.MaxInt64, 0))}, 73 | {"Uint", Field{zapField: zap.Field{Key: "k", Type: zapcore.Uint64Type, Integer: 1}}, Uint("k", 1)}, 74 | {"Uint64", Field{zapField: zap.Field{Key: "k", Type: zapcore.Uint64Type, Integer: 1}}, Uint64("k", 1)}, 75 | {"Uint32", Field{zapField: zap.Field{Key: "k", Type: zapcore.Uint32Type, Integer: 1}}, Uint32("k", 1)}, 76 | {"Uint16", Field{zapField: zap.Field{Key: "k", Type: zapcore.Uint16Type, Integer: 1}}, Uint16("k", 1)}, 77 | {"Uint8", Field{zapField: zap.Field{Key: "k", Type: zapcore.Uint8Type, Integer: 1}}, Uint8("k", 1)}, 78 | {"Uintptr", Field{zapField: zap.Field{Key: "k", Type: zapcore.UintptrType, Integer: 10}}, Uintptr("k", 0xa)}, 79 | {"Reflect", Field{zapField: zap.Field{Key: "k", Type: zapcore.ReflectType, Interface: ints}}, Reflect("k", ints)}, 80 | {"Reflect", Field{zapField: zap.Field{Key: "k", Type: zapcore.ReflectType}}, Reflect("k", nil)}, 81 | {"Stringer", Field{zapField: zap.Field{Key: "k", Type: zapcore.StringerType, Interface: addr}}, Stringer("k", addr)}, 82 | {"Object", Field{zapField: zap.Field{Key: "k", Type: zapcore.ObjectMarshalerType, Interface: name}}, Object("k", name)}, 83 | {"Any:ObjectMarshaler", Any("k", name), Object("k", name)}, 84 | {"Any:Stringer", Any("k", addr), Stringer("k", addr)}, 85 | {"Any:Bool", Any("k", true), Bool("k", true)}, 86 | {"Any:Byte", Any("k", byte(1)), Uint8("k", 1)}, 87 | {"Any:Bytes", Any("k", []byte{1}), Binary("k", []byte{1})}, 88 | {"Any:Complex128", Any("k", 1+2i), Complex128("k", 1+2i)}, 89 | {"Any:Complex64", Any("k", complex64(1+2i)), Complex64("k", 1+2i)}, 90 | {"Any:Float64", Any("k", 3.14), Float64("k", 3.14)}, 91 | {"Any:Float32", Any("k", float32(3.14)), Float32("k", 3.14)}, 92 | {"Any:Int", Any("k", 1), Int("k", 1)}, 93 | {"Any:Int64", Any("k", int64(1)), Int64("k", 1)}, 94 | {"Any:Int32", Any("k", int32(1)), Int32("k", 1)}, 95 | {"Any:Int16", Any("k", int16(1)), Int16("k", 1)}, 96 | {"Any:Int8", Any("k", int8(1)), Int8("k", 1)}, 97 | {"Any:Rune", Any("k", rune(1)), Int32("k", 1)}, 98 | {"Any:String", Any("k", "v"), String("k", "v")}, 99 | {"Any:Uint", Any("k", uint(1)), Uint("k", 1)}, 100 | {"Any:Uint64", Any("k", uint64(1)), Uint64("k", 1)}, 101 | {"Any:Uint32", Any("k", uint32(1)), Uint32("k", 1)}, 102 | {"Any:Uint16", Any("k", uint16(1)), Uint16("k", 1)}, 103 | {"Any:Uint8", Any("k", uint8(1)), Uint8("k", 1)}, 104 | {"Any:Uint8s", Any("k", []uint8{1}), Binary("k", []uint8{1})}, 105 | {"Any:Uintptr", Any("k", uintptr(1)), Uintptr("k", 1)}, 106 | {"Any:Time", Any("k", time.Unix(0, 0)), Time("k", time.Unix(0, 0))}, 107 | {"Any:Duration", Any("k", time.Second), Duration("k", time.Second)}, 108 | {"Any:Fallback", Any("k", struct{}{}), Reflect("k", struct{}{})}, 109 | {"Ptr:Bool", Boolp("k", &boolVal), Bool("k", boolVal)}, 110 | {"Any:PtrBool", Any("k", &boolVal), Bool("k", boolVal)}, 111 | {"Ptr:Complex128", Complex128p("k", &complex128Val), Complex128("k", complex128Val)}, 112 | {"Any:PtrComplex128", Any("k", &complex128Val), Complex128("k", complex128Val)}, 113 | {"Ptr:Complex64", Complex64p("k", &complex64Val), Complex64("k", complex64Val)}, 114 | {"Any:PtrComplex64", Any("k", &complex64Val), Complex64("k", complex64Val)}, 115 | {"Ptr:Duration", Durationp("k", &durationVal), Duration("k", durationVal)}, 116 | {"Any:PtrDuration", Any("k", &durationVal), Duration("k", durationVal)}, 117 | {"Ptr:Float64", Float64p("k", &float64Val), Float64("k", float64Val)}, 118 | {"Any:PtrFloat64", Any("k", &float64Val), Float64("k", float64Val)}, 119 | {"Ptr:Float32", Float32p("k", &float32Val), Float32("k", float32Val)}, 120 | {"Any:PtrFloat32", Any("k", &float32Val), Float32("k", float32Val)}, 121 | {"Ptr:Int", Intp("k", &intVal), Int("k", intVal)}, 122 | {"Any:PtrInt", Any("k", &intVal), Int("k", intVal)}, 123 | {"Ptr:Int64", Int64p("k", &int64Val), Int64("k", int64Val)}, 124 | {"Any:PtrInt64", Any("k", &int64Val), Int64("k", int64Val)}, 125 | {"Ptr:Int32", Int32p("k", &int32Val), Int32("k", int32Val)}, 126 | {"Any:PtrInt32", Any("k", &int32Val), Int32("k", int32Val)}, 127 | {"Ptr:Int16", Int16p("k", &int16Val), Int16("k", int16Val)}, 128 | {"Any:PtrInt16", Any("k", &int16Val), Int16("k", int16Val)}, 129 | {"Ptr:Int8", Int8p("k", &int8Val), Int8("k", int8Val)}, 130 | {"Any:PtrInt8", Any("k", &int8Val), Int8("k", int8Val)}, 131 | {"Ptr:String", Stringp("k", &stringVal), String("k", stringVal)}, 132 | {"Any:PtrString", Any("k", &stringVal), String("k", stringVal)}, 133 | {"Ptr:Time", Timep("k", &timeVal), Time("k", timeVal)}, 134 | {"Any:PtrTime", Any("k", &timeVal), Time("k", timeVal)}, 135 | {"Ptr:Uint", Uintp("k", &uintVal), Uint("k", uintVal)}, 136 | {"Any:PtrUint", Any("k", &uintVal), Uint("k", uintVal)}, 137 | {"Ptr:Uint64", Uint64p("k", &uint64Val), Uint64("k", uint64Val)}, 138 | {"Any:PtrUint64", Any("k", &uint64Val), Uint64("k", uint64Val)}, 139 | {"Ptr:Uint32", Uint32p("k", &uint32Val), Uint32("k", uint32Val)}, 140 | {"Any:PtrUint32", Any("k", &uint32Val), Uint32("k", uint32Val)}, 141 | {"Ptr:Uint16", Uint16p("k", &uint16Val), Uint16("k", uint16Val)}, 142 | {"Any:PtrUint16", Any("k", &uint16Val), Uint16("k", uint16Val)}, 143 | {"Ptr:Uint8", Uint8p("k", &uint8Val), Uint8("k", uint8Val)}, 144 | {"Any:PtrUint8", Any("k", &uint8Val), Uint8("k", uint8Val)}, 145 | {"Ptr:Uintptr", Uintptrp("k", &uintptrVal), Uintptr("k", uintptrVal)}, 146 | {"Any:PtrUintptr", Any("k", &uintptrVal), Uintptr("k", uintptrVal)}, 147 | } 148 | 149 | for _, tt := range tests { 150 | if !assert.Equal(t, tt.expect, tt.field, "Unexpected output from convenience field constructor %s.", tt.name) { 151 | t.Logf("type expected: %T\nGot: %T", tt.expect.zapField.Interface, tt.field.zapField.Interface) 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /pool/pool_test.go: -------------------------------------------------------------------------------- 1 | package pool 2 | 3 | import ( 4 | "errors" 5 | "github.com/agiledragon/gomonkey" 6 | "github.com/changsongl/delay-queue/job" 7 | "github.com/changsongl/delay-queue/pkg/lock" 8 | log "github.com/changsongl/delay-queue/test/mock/log" 9 | mocklock "github.com/changsongl/delay-queue/test/mock/pkg/lock" 10 | store "github.com/changsongl/delay-queue/test/mock/store" 11 | "github.com/golang/mock/gomock" 12 | "github.com/stretchr/testify/require" 13 | "testing" 14 | ) 15 | 16 | func TestCreateJobNewJobErr(t *testing.T) { 17 | ctrl := gomock.NewController(t) 18 | defer ctrl.Finish() 19 | 20 | mockStore := store.NewMockStore(ctrl) 21 | mockLogger := log.NewMockLogger(ctrl) 22 | mockLogger.EXPECT().WithModule(gomock.Any()).Return(mockLogger) 23 | p := New(mockStore, mockLogger) 24 | 25 | expJob := &job.Job{} 26 | expErr := errors.New("job error") 27 | 28 | gomonkey.ApplyFunc(job.New, func(topic job.Topic, id job.ID, delay job.Delay, ttr job.TTR, 29 | body job.Body, lockerFunc lock.LockerFunc) (*job.Job, error) { 30 | return expJob, expErr 31 | }) 32 | 33 | j, err := p.CreateJob("", "", 1, 1, "", true) 34 | require.Equal(t, expErr, err) 35 | require.Nil(t, j) 36 | } 37 | 38 | func TestCreateJobLockErr(t *testing.T) { 39 | ctrl := gomock.NewController(t) 40 | defer ctrl.Finish() 41 | 42 | mockStore := store.NewMockStore(ctrl) 43 | 44 | mockLogger := log.NewMockLogger(ctrl) 45 | mockLogger.EXPECT().WithModule(gomock.Any()).Return(mockLogger) 46 | 47 | mockLock := mocklock.NewMockLocker(ctrl) 48 | expJob := &job.Job{Mutex: mockLock} 49 | 50 | expErr := errors.New("lock error") 51 | mockLock.EXPECT().Lock().Return(expErr) 52 | 53 | p := New(mockStore, mockLogger) 54 | 55 | gomonkey.ApplyFunc(job.New, func(topic job.Topic, id job.ID, delay job.Delay, ttr job.TTR, 56 | body job.Body, lockerFunc lock.LockerFunc) (*job.Job, error) { 57 | return expJob, nil 58 | }) 59 | 60 | j, err := p.CreateJob("", "", 1, 1, "", true) 61 | require.Equal(t, expErr, err) 62 | require.Nil(t, j) 63 | } 64 | 65 | func TestCreateJobUnlockErr(t *testing.T) { 66 | ctrl := gomock.NewController(t) 67 | defer ctrl.Finish() 68 | 69 | expErr := errors.New("lock error") 70 | testCases := []struct { 71 | unlockResult bool 72 | unlockErr error 73 | isError bool 74 | }{ 75 | {unlockResult: false, unlockErr: expErr, isError: true}, 76 | {unlockResult: true, unlockErr: nil, isError: false}, 77 | {unlockResult: false, unlockErr: nil, isError: true}, 78 | } 79 | 80 | for i, testCase := range testCases { 81 | t.Logf("run test case %d", i) 82 | mockStore := store.NewMockStore(ctrl) 83 | mockStore.EXPECT().CreateJob(gomock.Any()).Return(nil) 84 | 85 | mockLogger := log.NewMockLogger(ctrl) 86 | mockLogger.EXPECT().WithModule(gomock.Any()).Return(mockLogger) 87 | if testCase.isError { 88 | mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()) 89 | } 90 | 91 | mockLock := mocklock.NewMockLocker(ctrl) 92 | expJob := &job.Job{Mutex: mockLock} 93 | mockLock.EXPECT().Lock().Return(nil) 94 | mockLock.EXPECT().Unlock().Return(testCase.unlockResult, testCase.unlockErr) 95 | p := New(mockStore, mockLogger) 96 | 97 | gomonkey.ApplyFunc(job.New, func(topic job.Topic, id job.ID, delay job.Delay, ttr job.TTR, 98 | body job.Body, lockerFunc lock.LockerFunc) (*job.Job, error) { 99 | return expJob, nil 100 | }) 101 | 102 | j, err := p.CreateJob("", "", 1, 1, "", false) 103 | require.Nil(t, err) 104 | require.Equal(t, expJob, j) 105 | } 106 | } 107 | 108 | func TestCreateJobCreateOrReplace(t *testing.T) { 109 | ctrl := gomock.NewController(t) 110 | defer ctrl.Finish() 111 | 112 | expErr := errors.New("test error") 113 | testCases := []struct { 114 | isReplace bool 115 | expError error 116 | }{ 117 | {isReplace: false, expError: nil}, 118 | {isReplace: false, expError: expErr}, 119 | {isReplace: true, expError: nil}, 120 | {isReplace: true, expError: expErr}, 121 | } 122 | 123 | for i, testCase := range testCases { 124 | t.Logf("run test case %d", i) 125 | mockStore := store.NewMockStore(ctrl) 126 | if testCase.isReplace { 127 | mockStore.EXPECT().ReplaceJob(gomock.Any()).Return(testCase.expError) 128 | } else { 129 | mockStore.EXPECT().CreateJob(gomock.Any()).Return(testCase.expError) 130 | } 131 | 132 | mockLogger := log.NewMockLogger(ctrl) 133 | mockLogger.EXPECT().WithModule(gomock.Any()).Return(mockLogger) 134 | 135 | mockLock := mocklock.NewMockLocker(ctrl) 136 | expJob := &job.Job{Mutex: mockLock} 137 | mockLock.EXPECT().Lock().Return(nil) 138 | mockLock.EXPECT().Unlock().Return(true, nil) 139 | p := New(mockStore, mockLogger) 140 | 141 | gomonkey.ApplyFunc(job.New, func(topic job.Topic, id job.ID, delay job.Delay, ttr job.TTR, 142 | body job.Body, lockerFunc lock.LockerFunc) (*job.Job, error) { 143 | return expJob, nil 144 | }) 145 | 146 | j, err := p.CreateJob("", "", 1, 1, "", testCase.isReplace) 147 | require.Equal(t, testCase.expError, err) 148 | if testCase.expError != nil { 149 | require.Nil(t, j) 150 | } else { 151 | require.Equal(t, expJob, j) 152 | } 153 | } 154 | } 155 | 156 | func TestLoadReadyJob(t *testing.T) { 157 | ctrl := gomock.NewController(t) 158 | defer ctrl.Finish() 159 | 160 | mockStore := store.NewMockStore(ctrl) 161 | mockLogger := log.NewMockLogger(ctrl) 162 | mockLogger.EXPECT().WithModule(gomock.Any()).Return(mockLogger) 163 | ver := job.NewVersion() 164 | j := &job.Job{Version: ver} 165 | 166 | // test case 1 job.Get error 167 | jobErr := errors.New("job err") 168 | gomonkey.ApplyFunc(job.Get, func(topic job.Topic, id job.ID, lockerFunc lock.LockerFunc) (*job.Job, error) { 169 | return nil, jobErr 170 | }) 171 | p := New(mockStore, mockLogger) 172 | jRet, err := p.LoadReadyJob("", "", ver) 173 | require.Nil(t, jRet) 174 | require.Equal(t, jobErr, err) 175 | 176 | // test case 2: s.LoadJob error 177 | loadErr := errors.New("load err") 178 | gomonkey.ApplyFunc(job.Get, func(topic job.Topic, id job.ID, lockerFunc lock.LockerFunc) (*job.Job, error) { 179 | return j, nil 180 | }) 181 | mockStore.EXPECT().LoadJob(j).Return(loadErr) 182 | jRet, err = p.LoadReadyJob("", "", ver) 183 | require.Nil(t, jRet) 184 | require.Equal(t, loadErr, err) 185 | 186 | // test case 3 j.IsVersionSame error 187 | gomonkey.ApplyFunc(job.Get, func(topic job.Topic, id job.ID, lockerFunc lock.LockerFunc) (*job.Job, error) { 188 | return j, nil 189 | }) 190 | mockStore.EXPECT().LoadJob(j).Return(nil) 191 | jRet, err = p.LoadReadyJob("", "", job.NewVersion()) 192 | require.Nil(t, jRet) 193 | require.Equal(t, ErrVersionNotSame, err) 194 | 195 | // test case 4 pass 196 | gomonkey.ApplyFunc(job.Get, func(topic job.Topic, id job.ID, lockerFunc lock.LockerFunc) (*job.Job, error) { 197 | return j, nil 198 | }) 199 | mockStore.EXPECT().LoadJob(j).Return(nil) 200 | jRet, err = p.LoadReadyJob("", "", ver) 201 | require.Equal(t, j, jRet) 202 | require.Nil(t, err) 203 | } 204 | 205 | func TestLoadDeleteJob(t *testing.T) { 206 | ctrl := gomock.NewController(t) 207 | defer ctrl.Finish() 208 | 209 | mockStore := store.NewMockStore(ctrl) 210 | mockLogger := log.NewMockLogger(ctrl) 211 | mockLogger.EXPECT().WithModule(gomock.Any()).Return(mockLogger) 212 | mockLock := mocklock.NewMockLocker(ctrl) 213 | 214 | j := &job.Job{Mutex: mockLock} 215 | p := New(mockStore, mockLogger) 216 | 217 | // test case 1: job.Get 218 | jobGetErr := errors.New("job err") 219 | gomonkey.ApplyFunc(job.Get, func(topic job.Topic, id job.ID, lockerFunc lock.LockerFunc) (*job.Job, error) { 220 | return nil, jobGetErr 221 | }) 222 | err := p.DeleteJob("", "") 223 | require.Equal(t, jobGetErr, err) 224 | 225 | // test case 2: j.Lock error 226 | lockErr := errors.New("lock err") 227 | gomonkey.ApplyFunc(job.Get, func(topic job.Topic, id job.ID, lockerFunc lock.LockerFunc) (*job.Job, error) { 228 | return j, nil 229 | }) 230 | mockLock.EXPECT().Lock().Return(lockErr) 231 | err = p.DeleteJob("", "") 232 | require.Equal(t, lockErr, err) 233 | 234 | // test case 3: DeleteJob cases 235 | deleteErr := errors.New("delete err") 236 | unlockErr := errors.New("unlock err") 237 | cases := []struct { 238 | Err error 239 | Result bool 240 | ExpErr error 241 | UnlockResult bool 242 | UnlockErr error 243 | LogError bool 244 | }{ 245 | {Err: deleteErr, Result: false, ExpErr: deleteErr, UnlockResult: false, UnlockErr: unlockErr, LogError: true}, 246 | {Err: deleteErr, Result: true, ExpErr: deleteErr, UnlockResult: true, UnlockErr: unlockErr, LogError: true}, 247 | {Err: nil, Result: true, ExpErr: nil, UnlockResult: true, UnlockErr: nil, LogError: false}, 248 | {Err: nil, Result: false, ExpErr: ErrJobNotExist, UnlockResult: false, UnlockErr: nil, LogError: true}, 249 | } 250 | 251 | idParam, topicParam := "id", "topic" 252 | 253 | for _, test := range cases { 254 | gomonkey.ApplyFunc(job.Get, func(topic job.Topic, id job.ID, lockerFunc lock.LockerFunc) (*job.Job, error) { 255 | require.EqualValues(t, topicParam, topic) 256 | require.EqualValues(t, idParam, id) 257 | return j, nil 258 | }) 259 | mockLock.EXPECT().Lock().Return(nil) 260 | mockLock.EXPECT().Unlock().Return(test.UnlockResult, test.UnlockErr) 261 | mockStore.EXPECT().DeleteJob(j).Return(test.Result, test.Err) 262 | if test.LogError { 263 | mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return() 264 | } 265 | 266 | err := p.DeleteJob(job.Topic(topicParam), job.ID(idParam)) 267 | if test.ExpErr != nil { 268 | require.Equal(t, test.ExpErr, err) 269 | } else { 270 | require.Nil(t, err) 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /pkg/log/field.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | // Field log filed 12 | type Field struct { 13 | zapField zap.Field 14 | } 15 | 16 | // get zap field 17 | func (f Field) getZapField() zap.Field { 18 | return f.zapField 19 | } 20 | 21 | // convert Field to zap fields 22 | func getZapFields(fs ...Field) []zap.Field { 23 | fields := make([]zap.Field, 0, len(fs)) 24 | for _, f := range fs { 25 | fields = append(fields, f.getZapField()) 26 | } 27 | 28 | return fields 29 | } 30 | 31 | // Binary constructs a field that carries a binary. 32 | func Binary(key string, val []byte) Field { 33 | return Field{zapField: zap.Binary(key, val)} 34 | } 35 | 36 | // Bool constructs a field that carries a bool. 37 | func Bool(key string, val bool) Field { 38 | return Field{zapField: zap.Bool(key, val)} 39 | } 40 | 41 | // Boolp constructs a field that carries a *bool. The returned Field will safely 42 | // and explicitly represent `nil` when appropriate. 43 | func Boolp(key string, val *bool) Field { 44 | return Field{zapField: zap.Boolp(key, val)} 45 | } 46 | 47 | // ByteString constructs a field that carries UTF-8 encoded text as a []byte. 48 | // To log opaque binary blobs (which aren't necessarily valid UTF-8), use 49 | // Binary. 50 | func ByteString(key string, val []byte) Field { 51 | return Field{zapField: zap.ByteString(key, val)} 52 | } 53 | 54 | // Complex128 constructs a field that carries a complex number. Unlike most 55 | // numeric fields, this costs an allocation (to convert the complex128 to 56 | // interface{}). 57 | func Complex128(key string, val complex128) Field { 58 | return Field{zapField: zap.Complex128(key, val)} 59 | } 60 | 61 | // Complex128p constructs a field that carries a *complex128. The returned Field will safely 62 | // and explicitly represent `nil` when appropriate. 63 | func Complex128p(key string, val *complex128) Field { 64 | return Field{zapField: zap.Complex128p(key, val)} 65 | } 66 | 67 | // Complex64 constructs a field that carries a complex number. Unlike most 68 | // numeric fields, this costs an allocation (to convert the complex64 to 69 | // interface{}). 70 | func Complex64(key string, val complex64) Field { 71 | return Field{zapField: zap.Complex64(key, val)} 72 | } 73 | 74 | // Complex64p constructs a field that carries a *complex64. The returned Field will safely 75 | // and explicitly represent `nil` when appropriate. 76 | func Complex64p(key string, val *complex64) Field { 77 | return Field{zapField: zap.Complex64p(key, val)} 78 | } 79 | 80 | // Float64 constructs a field that carries a float64. The way the 81 | // floating-point value is represented is encoder-dependent, so marshaling is 82 | // necessarily lazy. 83 | func Float64(key string, val float64) Field { 84 | return Field{zapField: zap.Float64(key, val)} 85 | } 86 | 87 | // Float64p constructs a field that carries a *float64. The returned Field will safely 88 | // and explicitly represent `nil` when appropriate. 89 | func Float64p(key string, val *float64) Field { 90 | return Field{zapField: zap.Float64p(key, val)} 91 | } 92 | 93 | // Float32 constructs a field that carries a float32. The way the 94 | // floating-point value is represented is encoder-dependent, so marshaling is 95 | // necessarily lazy. 96 | func Float32(key string, val float32) Field { 97 | return Field{zapField: zap.Float32(key, val)} 98 | } 99 | 100 | // Float32p constructs a field that carries a *float32. The returned Field will safely 101 | // and explicitly represent `nil` when appropriate. 102 | func Float32p(key string, val *float32) Field { 103 | return Field{zapField: zap.Float32p(key, val)} 104 | } 105 | 106 | // Int constructs a field with the given key and value. 107 | func Int(key string, val int) Field { 108 | return Field{zapField: zap.Int(key, val)} 109 | } 110 | 111 | // Intp constructs a field that carries a *int. The returned Field will safely 112 | // and explicitly represent `nil` when appropriate. 113 | func Intp(key string, val *int) Field { 114 | return Field{zapField: zap.Intp(key, val)} 115 | } 116 | 117 | // Int64 constructs a field with the given key and value. 118 | func Int64(key string, val int64) Field { 119 | return Field{zapField: zap.Int64(key, val)} 120 | } 121 | 122 | // Int64p constructs a field that carries a *int64. The returned Field will safely 123 | // and explicitly represent `nil` when appropriate. 124 | func Int64p(key string, val *int64) Field { 125 | return Field{zapField: zap.Int64p(key, val)} 126 | } 127 | 128 | // Int32 constructs a field with the given key and value. 129 | func Int32(key string, val int32) Field { 130 | return Field{zapField: zap.Int32(key, val)} 131 | } 132 | 133 | // Int32p constructs a field that carries a *int32. The returned Field will safely 134 | // and explicitly represent `nil` when appropriate. 135 | func Int32p(key string, val *int32) Field { 136 | return Field{zapField: zap.Int32p(key, val)} 137 | } 138 | 139 | // Int16 constructs a field with the given key and value. 140 | func Int16(key string, val int16) Field { 141 | return Field{zapField: zap.Int16(key, val)} 142 | } 143 | 144 | // Int16p constructs a field that carries a *int16. The returned Field will safely 145 | // and explicitly represent `nil` when appropriate. 146 | func Int16p(key string, val *int16) Field { 147 | return Field{zapField: zap.Int16p(key, val)} 148 | } 149 | 150 | // Int8 constructs a field with the given key and value. 151 | func Int8(key string, val int8) Field { 152 | return Field{zapField: zap.Int8(key, val)} 153 | } 154 | 155 | // Int8p constructs a field that carries a *int8. The returned Field will safely 156 | // and explicitly represent `nil` when appropriate. 157 | func Int8p(key string, val *int8) Field { 158 | return Field{zapField: zap.Int8p(key, val)} 159 | } 160 | 161 | // String constructs a field with the given key and value. 162 | func String(key string, val string) Field { 163 | return Field{zapField: zap.String(key, val)} 164 | } 165 | 166 | // Stringp constructs a field that carries a *string. The returned Field will safely 167 | // and explicitly represent `nil` when appropriate. 168 | func Stringp(key string, val *string) Field { 169 | return Field{zapField: zap.Stringp(key, val)} 170 | } 171 | 172 | // Uint constructs a field with the given key and value. 173 | func Uint(key string, val uint) Field { 174 | return Field{zapField: zap.Uint(key, val)} 175 | } 176 | 177 | // Uintp constructs a field that carries a *uint. The returned Field will safely 178 | // and explicitly represent `nil` when appropriate. 179 | func Uintp(key string, val *uint) Field { 180 | return Field{zapField: zap.Uintp(key, val)} 181 | } 182 | 183 | // Uint64 constructs a field with the given key and value. 184 | func Uint64(key string, val uint64) Field { 185 | return Field{zapField: zap.Uint64(key, val)} 186 | } 187 | 188 | // Uint64p constructs a field that carries a *uint64. The returned Field will safely 189 | // and explicitly represent `nil` when appropriate. 190 | func Uint64p(key string, val *uint64) Field { 191 | return Field{zapField: zap.Uint64p(key, val)} 192 | } 193 | 194 | // Uint32 constructs a field with the given key and value. 195 | func Uint32(key string, val uint32) Field { 196 | return Field{zapField: zap.Uint32(key, val)} 197 | } 198 | 199 | // Uint32p constructs a field that carries a *uint32. The returned Field will safely 200 | // and explicitly represent `nil` when appropriate. 201 | func Uint32p(key string, val *uint32) Field { 202 | return Field{zapField: zap.Uint32p(key, val)} 203 | } 204 | 205 | // Uint16 constructs a field with the given key and value. 206 | func Uint16(key string, val uint16) Field { 207 | return Field{zapField: zap.Uint16(key, val)} 208 | } 209 | 210 | // Uint16p constructs a field that carries a *uint16. The returned Field will safely 211 | // and explicitly represent `nil` when appropriate. 212 | func Uint16p(key string, val *uint16) Field { 213 | return Field{zapField: zap.Uint16p(key, val)} 214 | } 215 | 216 | // Uint8 constructs a field with the given key and value. 217 | func Uint8(key string, val uint8) Field { 218 | return Field{zapField: zap.Uint8(key, val)} 219 | } 220 | 221 | // Uint8p constructs a field that carries a *uint8. The returned Field will safely 222 | // and explicitly represent `nil` when appropriate. 223 | func Uint8p(key string, val *uint8) Field { 224 | return Field{zapField: zap.Uint8p(key, val)} 225 | } 226 | 227 | // Uintptr constructs a field with the given key and value. 228 | func Uintptr(key string, val uintptr) Field { 229 | return Field{zapField: zap.Uintptr(key, val)} 230 | } 231 | 232 | // Uintptrp constructs a field that carries a *uintptr. The returned Field will safely 233 | // and explicitly represent `nil` when appropriate. 234 | func Uintptrp(key string, val *uintptr) Field { 235 | return Field{zapField: zap.Uintptrp(key, val)} 236 | } 237 | 238 | // Reflect constructs a field with the given key and an arbitrary object. It uses 239 | // an encoding-appropriate, reflection-based function to lazily serialize nearly 240 | // any object into the logging context, but it's relatively slow and 241 | // allocation-heavy. Outside tests, Any is always a better choice. 242 | // 243 | // If encoding fails (e.g., trying to serialize a map[int]string to JSON), Reflect 244 | // includes the error message in the final log output. 245 | func Reflect(key string, val interface{}) Field { 246 | return Field{zapField: zap.Reflect(key, val)} 247 | } 248 | 249 | // Namespace creates a named, isolated scope within the logger's context. All 250 | // subsequent fields will be added to the new namespace. 251 | // 252 | // This helps prevent key collisions when injecting loggers into sub-components 253 | // or third-party libraries. 254 | func Namespace(key string) Field { 255 | return Field{zapField: zap.Namespace(key)} 256 | } 257 | 258 | // Stringer constructs a field with the given key and the output of the value's 259 | // String method. The Stringer's String method is called lazily. 260 | func Stringer(key string, val fmt.Stringer) Field { 261 | return Field{zapField: zap.Stringer(key, val)} 262 | } 263 | 264 | // Time constructs a Field with the given key and value. The encoder 265 | // controls how the time is serialized. 266 | func Time(key string, val time.Time) Field { 267 | return Field{zapField: zap.Time(key, val)} 268 | } 269 | 270 | // Timep constructs a field that carries a *time.Time. The returned Field will safely 271 | // and explicitly represent `nil` when appropriate. 272 | func Timep(key string, val *time.Time) Field { 273 | return Field{zapField: zap.Timep(key, val)} 274 | } 275 | 276 | // Stack constructs a field that stores a stacktrace of the current goroutine 277 | // under provided key. Keep in mind that taking a stacktrace is eager and 278 | // expensive (relatively speaking); this function both makes an allocation and 279 | // takes about two microseconds. 280 | func Stack(key string) Field { 281 | return Field{zapField: zap.Stack(key)} 282 | } 283 | 284 | // StackSkip constructs a field similarly to Stack, but also skips the given 285 | // number of frames from the top of the stacktrace. 286 | func StackSkip(key string, skip int) Field { 287 | return Field{zapField: zap.StackSkip(key, skip)} 288 | } 289 | 290 | // Duration constructs a field with the given key and value. The encoder 291 | // controls how the duration is serialized. 292 | func Duration(key string, val time.Duration) Field { 293 | return Field{zapField: zap.Duration(key, val)} 294 | } 295 | 296 | // Durationp constructs a field that carries a *time.Duration. The returned Field will safely 297 | // and explicitly represent `nil` when appropriate. 298 | func Durationp(key string, val *time.Duration) Field { 299 | return Field{zapField: zap.Durationp(key, val)} 300 | } 301 | 302 | // Object constructs a field with the given key and ObjectMarshaler. It 303 | // provides a flexible, but still type-safe and efficient, way to add map- or 304 | // struct-like user-defined types to the logging context. The struct's 305 | // MarshalLogObject method is called lazily. 306 | func Object(key string, val zapcore.ObjectMarshaler) Field { 307 | return Field{zapField: zap.Object(key, val)} 308 | } 309 | 310 | // Any takes a key and an arbitrary value and chooses the best way to represent 311 | // them as a field, falling back to a reflection-based approach only if 312 | // necessary. 313 | // 314 | // Since byte/uint8 and rune/int32 are aliases, Any can't differentiate between 315 | // them. To minimize surprises, []byte values are treated as binary blobs, byte 316 | // values are treated as uint8, and runes are always treated as integers. 317 | func Any(key string, value interface{}) Field { 318 | return Field{zapField: zap.Any(key, value)} 319 | } 320 | 321 | // Error constructs a field of error 322 | func Error(err error) Field { 323 | return Field{zapField: zap.Error(err)} 324 | } 325 | --------------------------------------------------------------------------------