init repo
This commit is contained in:
commit
052958eb7e
0
.gitignore
vendored
Normal file
0
.gitignore
vendored
Normal file
133
.golangci.yml
Normal file
133
.golangci.yml
Normal file
@ -0,0 +1,133 @@
|
||||
linters-settings:
|
||||
# depguard: // Specific for golangci repository
|
||||
# list-type: blacklist
|
||||
# packages:
|
||||
# # logging is allowed only by logutils.Log, logrus
|
||||
# # is allowed to use only in logutils package
|
||||
# - github.com/sirupsen/logrus
|
||||
# packages-with-error-message:
|
||||
# - github.com/sirupsen/logrus: 'logging is allowed only by logutils.Log'
|
||||
dupl:
|
||||
threshold: 100
|
||||
funlen:
|
||||
lines: 100
|
||||
statements: 50
|
||||
gci:
|
||||
sections:
|
||||
prefix(fetchsysd)
|
||||
goconst:
|
||||
min-len: 2
|
||||
min-occurrences: 2
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
- experimental
|
||||
- opinionated
|
||||
- performance
|
||||
- style
|
||||
disabled-checks:
|
||||
- dupImport # https://github.com/go-critic/go-critic/issues/845
|
||||
- ifElseChain
|
||||
- octalLiteral
|
||||
# - whyNoLint
|
||||
- wrapperFunc
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
goimports:
|
||||
local-prefixes: localenv
|
||||
mnd:
|
||||
# don't include the "operation" and "assign"
|
||||
checks:
|
||||
- argument
|
||||
- case
|
||||
- condition
|
||||
- return
|
||||
govet:
|
||||
shadow: true
|
||||
# settings: // Specific for golangci repository
|
||||
# printf:
|
||||
# funcs:
|
||||
# - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof
|
||||
# - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf
|
||||
# - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf
|
||||
# - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf
|
||||
lll:
|
||||
line-length: 200
|
||||
maligned:
|
||||
suggest-new: true
|
||||
misspell:
|
||||
locale: US
|
||||
nolintlint:
|
||||
allow-leading-space: true # don't require machine-readable nolint directives (i.e. with no leading space)
|
||||
allow-unused: false # report any unused nolint directives
|
||||
require-explanation: false # don't require an explanation for nolint directives
|
||||
require-specific: false # don't require nolint directives to be specific about which linter is being skipped
|
||||
errcheck:
|
||||
check-blank: true
|
||||
exclude-functions:
|
||||
- '(*github.com/gin-gonic/gin.Error).SetType'
|
||||
- '(*github.com/gin-gonic/gin.Context).Error'
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- bodyclose
|
||||
# - deadcode # deprecated (since v1.49.0)
|
||||
# - depguard
|
||||
- dogsled
|
||||
- dupl
|
||||
- errcheck
|
||||
- copyloopvar
|
||||
- exhaustive
|
||||
- funlen
|
||||
- gochecknoinits
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- gofmt
|
||||
- goimports
|
||||
- mnd
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- lll
|
||||
- misspell
|
||||
- nakedret
|
||||
- noctx
|
||||
- nolintlint
|
||||
# - rowserrcheck # https://github.com/golangci/golangci-lint/issues/2649
|
||||
- staticcheck
|
||||
# - structcheck # https://github.com/golangci/golangci-lint/issues/2649
|
||||
- stylecheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
# - varcheck # deprecated (since v1.49.0)
|
||||
- whitespace
|
||||
# - gochecknoglobals # too many global in ds9
|
||||
|
||||
# don't enable:
|
||||
# - asciicheck
|
||||
# - scopelint
|
||||
# - gocognit
|
||||
# - godot
|
||||
# - godox
|
||||
# - goerr113
|
||||
# - interfacer
|
||||
# - maligned
|
||||
# - nestif
|
||||
# - prealloc
|
||||
# - testpackage
|
||||
# - revive
|
||||
# - wsl
|
||||
|
||||
# issues:
|
||||
# Excluding configuration per-path, per-linter, per-text and per-source
|
||||
# fix: true
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
skip-dirs: []
|
||||
8
Makefile
Normal file
8
Makefile
Normal file
@ -0,0 +1,8 @@
|
||||
run: lint
|
||||
go run main.go
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
test:
|
||||
go test ./... -race
|
||||
48
README.md
Normal file
48
README.md
Normal file
@ -0,0 +1,48 @@
|
||||
# cycle-scheduler
|
||||
|
||||
cycle-scheduler is a simple scheduler handling jobs and executes them at regular interval.
|
||||
|
||||
Here a simple representation:
|
||||
```ascii
|
||||
+------------------------------------------------------+
|
||||
| +---+ +---+ +---+ +---+ +---+ +---+ |
|
||||
| | | | | | | | | | | | | |
|
||||
| | | | | | | | | | | | | |
|
||||
| | | | | | | | | | | | | |
|
||||
| | | | | | | | | | | | | |
|
||||
| | | | | | | | | | | | | |
|
||||
| | | | | | | | | | | | | |
|
||||
| |s1 | |s2 | |s3 | |s4 | | | |s60| |
|
||||
| +---+ +---+ +---+ +---+ +---+ +---+ |
|
||||
+---------------^--------------------------------------+
|
||||
```
|
||||
Jobs are handle in a array of job slices.
|
||||
|
||||
At each interval (clock), the cursor `^` moves to the next slot (s*).
|
||||
If there are jobs, they are sent to workers to be executed
|
||||
and the slot is cleaned.
|
||||
At the end of the slot (s60), the cursor re-starts a new cycle from s1.
|
||||
|
||||
If a job is not in a desire state, the job is re-scheduled in the current slot to be re-executed in the next cycle.
|
||||
|
||||
**NOTE**: This scheduler does not accept long running tasks. Job execution have a fixed timeout of 10s.
|
||||
Pooling tasks are more suitable for this kind of scheduler.
|
||||
|
||||
## Run
|
||||
You can run sample tests from `main.go` to see the scheduler in action:
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
If all goes well, you should see this kind of output in the stdout:
|
||||
```ascii
|
||||
# cycle-scheduler (slot: 7)
|
||||
_ P _ _ _ _ _ _ _ _ _ _ _ _
|
||||
- - - - - - ^ - - - - - - -
|
||||
```
|
||||
> **P** means *pending* state
|
||||
|
||||
You can adjust the clock interval as needed in `main.go`:
|
||||
```go
|
||||
interval := 200 * time.Millisecond
|
||||
```
|
||||
|
||||
18
go.mod
Normal file
18
go.mod
Normal file
@ -0,0 +1,18 @@
|
||||
module cycle-scheduler
|
||||
|
||||
go 1.22.4
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
golang.org/x/sys v0.12.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
27
go.sum
Normal file
27
go.sum
Normal file
@ -0,0 +1,27 @@
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
166
internal/job/job.go
Normal file
166
internal/job/job.go
Normal file
@ -0,0 +1,166 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type State int
|
||||
|
||||
const (
|
||||
Pending State = iota
|
||||
Running
|
||||
Success
|
||||
Failed
|
||||
Abort
|
||||
Unknown
|
||||
)
|
||||
|
||||
const UnknownState = "unknown"
|
||||
|
||||
var JobExecTimeout = 10 * time.Second
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case Pending:
|
||||
return "pending"
|
||||
case Running:
|
||||
return "running"
|
||||
case Success:
|
||||
return "success"
|
||||
case Failed:
|
||||
return "failed"
|
||||
case Abort:
|
||||
return "abort"
|
||||
case Unknown:
|
||||
return UnknownState
|
||||
default:
|
||||
return UnknownState
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
ErrJobAborted = errors.New("job has been aborted")
|
||||
ErrJobNotCompletedYet = errors.New("job is not right state, retrying")
|
||||
)
|
||||
|
||||
type FnJob func(ctx context.Context) error
|
||||
|
||||
type JobDetails struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
State string `json:"state"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
Err string `json:"error"`
|
||||
}
|
||||
|
||||
// TODO(rmanach): add priority level
|
||||
type Job struct {
|
||||
l sync.RWMutex
|
||||
id uuid.UUID
|
||||
createdAt time.Time
|
||||
updatedAt *time.Time
|
||||
state State
|
||||
task FnJob
|
||||
err error
|
||||
chAbort chan struct{}
|
||||
}
|
||||
|
||||
func NewJob(task FnJob, row, col int) Job {
|
||||
return Job{
|
||||
id: uuid.New(),
|
||||
createdAt: time.Now().UTC(),
|
||||
state: Pending,
|
||||
task: task,
|
||||
chAbort: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (j *Job) IntoDetails() JobDetails {
|
||||
j.l.RLock()
|
||||
defer j.l.RUnlock()
|
||||
|
||||
jd := JobDetails{
|
||||
ID: j.id,
|
||||
CreatedAt: j.createdAt,
|
||||
State: j.state.String(),
|
||||
}
|
||||
|
||||
if err := j.err; err != nil {
|
||||
jd.Err = err.Error()
|
||||
}
|
||||
|
||||
if ut := j.updatedAt; ut != nil {
|
||||
jd.UpdatedAt = ut
|
||||
}
|
||||
|
||||
return jd
|
||||
}
|
||||
|
||||
func (j *Job) GetID() uuid.UUID {
|
||||
return j.id
|
||||
}
|
||||
|
||||
func (j *Job) GetState() State {
|
||||
j.l.RLock()
|
||||
defer j.l.RUnlock()
|
||||
|
||||
return j.state
|
||||
}
|
||||
|
||||
func (j *Job) setState(s State) {
|
||||
j.l.Lock()
|
||||
defer j.l.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
j.updatedAt = &now
|
||||
|
||||
j.state = s
|
||||
}
|
||||
|
||||
func (j *Job) setFail(err error) {
|
||||
j.l.Lock()
|
||||
defer j.l.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
j.updatedAt = &now
|
||||
|
||||
j.state = Failed
|
||||
j.err = err
|
||||
}
|
||||
|
||||
func (j *Job) Abort() {
|
||||
j.setState(Abort)
|
||||
j.chAbort <- struct{}{}
|
||||
}
|
||||
|
||||
func (j *Job) Run(ctx context.Context) {
|
||||
ctxExec, fnCancel := context.WithTimeout(ctx, JobExecTimeout)
|
||||
defer fnCancel()
|
||||
|
||||
j.setState(Running)
|
||||
|
||||
log.Info().Str("job", j.GetID().String()).Msg("job running...")
|
||||
|
||||
go func() {
|
||||
for range j.chAbort {
|
||||
j.setState(Abort)
|
||||
fnCancel()
|
||||
}
|
||||
}()
|
||||
|
||||
if err := j.task(ctxExec); err != nil {
|
||||
if errors.Is(err, ErrJobNotCompletedYet) {
|
||||
j.setState(Pending)
|
||||
return
|
||||
}
|
||||
j.setFail(err)
|
||||
return
|
||||
}
|
||||
j.setState(Success)
|
||||
}
|
||||
371
internal/scheduler/scheduler.go
Normal file
371
internal/scheduler/scheduler.go
Normal file
@ -0,0 +1,371 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cycle-scheduler/internal/job"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
TableTitle = "# cycle-scheduler"
|
||||
Cursor = "^"
|
||||
CycleLength = 60
|
||||
MaxWorkers = 5
|
||||
)
|
||||
|
||||
const MaxSlotsIdx = 59
|
||||
|
||||
type JobSlot struct {
|
||||
*job.Job
|
||||
row int
|
||||
}
|
||||
|
||||
// SchedulerCycle is a dumb scheduler.
|
||||
// It handle job and executes it at each cycle (60 * interval).
|
||||
//
|
||||
// Jobs are handle in a array of job slices.
|
||||
// At each interval (clock), the cursor moves to the next slot (s*).
|
||||
// If there are jobs, they are sent to workers to be executed
|
||||
// and the slot is cleaned.
|
||||
//
|
||||
// At the end of the slot (s60), the cursor re-starts a cycle at s1.
|
||||
type SchedulerCycle struct {
|
||||
l sync.RWMutex
|
||||
wg sync.WaitGroup
|
||||
|
||||
ctx context.Context
|
||||
fnCancel context.CancelFunc
|
||||
|
||||
interval time.Duration
|
||||
currentSlot int
|
||||
slots [60][]*job.Job
|
||||
jobs map[uuid.UUID]*job.Job
|
||||
|
||||
chJobs chan *JobSlot
|
||||
}
|
||||
|
||||
func NewSchedulerCycle(ctx context.Context, interval time.Duration) *SchedulerCycle {
|
||||
ctxChild, fnCancel := context.WithCancel(ctx)
|
||||
|
||||
c := SchedulerCycle{
|
||||
wg: sync.WaitGroup{},
|
||||
ctx: ctxChild,
|
||||
fnCancel: fnCancel,
|
||||
interval: interval,
|
||||
currentSlot: 0,
|
||||
slots: [60][]*job.Job{},
|
||||
jobs: make(map[uuid.UUID]*job.Job),
|
||||
chJobs: make(chan *JobSlot),
|
||||
}
|
||||
|
||||
c.run()
|
||||
|
||||
return &c
|
||||
}
|
||||
|
||||
func (c *SchedulerCycle) Stop() {
|
||||
c.fnCancel()
|
||||
}
|
||||
|
||||
func (c *SchedulerCycle) Done() <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
<-c.ctx.Done()
|
||||
c.wg.Done()
|
||||
done <- struct{}{}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
func (c *SchedulerCycle) Len() int {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
|
||||
return len(c.jobs)
|
||||
}
|
||||
|
||||
func (c *SchedulerCycle) HasAllJobsDone() bool {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
|
||||
for _, j := range c.jobs {
|
||||
if j.GetState() == job.Pending || j.GetState() == job.Running {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *SchedulerCycle) GetJobsDetails() []job.JobDetails {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
|
||||
details := []job.JobDetails{}
|
||||
for _, j := range c.jobs {
|
||||
details = append(details, j.IntoDetails())
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
// Delay builds a job and add it to the scheduler engine.
|
||||
func (c *SchedulerCycle) Delay(fnJob job.FnJob) uuid.UUID {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
|
||||
nextSlot := c.currentSlot + 1
|
||||
if nextSlot > MaxSlotsIdx {
|
||||
nextSlot = 0
|
||||
}
|
||||
|
||||
j := job.NewJob(fnJob, nextSlot, len(c.slots[nextSlot]))
|
||||
|
||||
c.slots[nextSlot] = append(c.slots[nextSlot], &j)
|
||||
c.jobs[j.GetID()] = &j
|
||||
|
||||
log.Info().Str("job", j.GetID().String()).Msg("job added successfully")
|
||||
return j.GetID()
|
||||
}
|
||||
|
||||
// Abort aborts the job given by its id if it exists..
|
||||
func (c *SchedulerCycle) Abort(id uuid.UUID) bool {
|
||||
if j := c.getJob(id); j != nil {
|
||||
j.Abort()
|
||||
|
||||
log.Info().Str("job", j.GetID().String()).Msg("abort job done")
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetJobDetails returns the job details by .
|
||||
func (c *SchedulerCycle) GetJobDetails(id uuid.UUID) job.JobDetails {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
|
||||
j, ok := c.jobs[id]
|
||||
if !ok {
|
||||
return job.JobDetails{
|
||||
State: job.Unknown.String(),
|
||||
}
|
||||
}
|
||||
|
||||
return j.IntoDetails()
|
||||
}
|
||||
|
||||
// Display outputs earch interval the scheduler state.
|
||||
func (c *SchedulerCycle) Display() {
|
||||
ticker := time.NewTicker(c.interval)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
c.display()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// display writes to stdout the state of the scheduler as a table.
|
||||
func (c *SchedulerCycle) display() { //nolint:gocyclo // not complex
|
||||
c.l.RLock()
|
||||
defer c.l.RUnlock()
|
||||
|
||||
var maxCols int
|
||||
for i := range c.slots {
|
||||
if l := len(c.slots[i]); l > maxCols {
|
||||
maxCols = l
|
||||
}
|
||||
}
|
||||
|
||||
table := [][]string{}
|
||||
title := fmt.Sprintf("%s (slot: %d)", TableTitle, c.currentSlot+1)
|
||||
table = append(table, []string{title})
|
||||
for {
|
||||
if maxCols == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
row := make([]string, CycleLength)
|
||||
for i := 0; i <= MaxSlotsIdx; i++ {
|
||||
row[i] = "_"
|
||||
}
|
||||
|
||||
for i := range c.slots {
|
||||
if len(c.slots[i]) < maxCols {
|
||||
continue
|
||||
}
|
||||
|
||||
j := c.slots[i][maxCols-1]
|
||||
switch j.GetState() {
|
||||
case job.Pending:
|
||||
row[i] = "P"
|
||||
case job.Running:
|
||||
row[i] = "R"
|
||||
case job.Failed:
|
||||
row[i] = "X"
|
||||
case job.Abort:
|
||||
row[i] = "A"
|
||||
case job.Unknown:
|
||||
row[i] = "?"
|
||||
case job.Success:
|
||||
row[i] = "O"
|
||||
}
|
||||
}
|
||||
|
||||
table = append(table, row)
|
||||
maxCols--
|
||||
}
|
||||
|
||||
row := make([]string, CycleLength)
|
||||
for i := 0; i <= MaxSlotsIdx; i++ {
|
||||
row[i] = "-"
|
||||
}
|
||||
table = append(table, row)
|
||||
|
||||
if l := len(table); l > 0 {
|
||||
table[l-1][c.currentSlot] = Cursor
|
||||
}
|
||||
|
||||
tableFormat := ""
|
||||
for _, r := range table {
|
||||
tableFormat += strings.Join(r, " ")
|
||||
tableFormat += "\n"
|
||||
}
|
||||
|
||||
fmt.Println(tableFormat)
|
||||
}
|
||||
|
||||
func (c *SchedulerCycle) getJob(id uuid.UUID) *job.Job {
|
||||
c.l.RLock()
|
||||
defer c.l.RUnlock()
|
||||
|
||||
j, ok := c.jobs[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return j
|
||||
}
|
||||
|
||||
// getCurrentSlotJobs collects all the current slot jobs
|
||||
// and clean the slot.
|
||||
func (c *SchedulerCycle) getCurrentSlotJobs() (int, []*job.Job) {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
|
||||
jobs := c.slots[c.currentSlot]
|
||||
|
||||
c.slots[c.currentSlot] = []*job.Job{}
|
||||
|
||||
return c.currentSlot, jobs
|
||||
}
|
||||
|
||||
// updateSlot add a job to the slot where it was before.
|
||||
func (c *SchedulerCycle) updateSlot(row int, j *job.Job) {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
|
||||
c.slots[row] = append(c.slots[row], j)
|
||||
}
|
||||
|
||||
// updateCurrentSlot add a job to the current slot.
|
||||
func (c *SchedulerCycle) updateCurrentSlot(j *job.Job) {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
|
||||
c.slots[c.currentSlot] = append(c.slots[c.currentSlot], j)
|
||||
}
|
||||
|
||||
// incr increments the slot cursor.
|
||||
// It the cursor reaches `MaxSlotsIdx`, it goes back to 0.
|
||||
func (c *SchedulerCycle) incr() {
|
||||
c.l.Lock()
|
||||
defer c.l.Unlock()
|
||||
|
||||
nextSlot := c.currentSlot + 1
|
||||
if nextSlot > MaxSlotsIdx {
|
||||
nextSlot = 0
|
||||
}
|
||||
|
||||
c.currentSlot = nextSlot
|
||||
}
|
||||
|
||||
// dispatch gets jobs from the current slot, resets the slot
|
||||
// and dispatch all jobs to the workers.
|
||||
//
|
||||
// It all the workers are busy, the jobs are re-schedule in the same slot
|
||||
// to be executed in the next cycle.
|
||||
func (c *SchedulerCycle) dispatch() {
|
||||
row, jobs := c.getCurrentSlotJobs()
|
||||
for _, j := range jobs {
|
||||
if j.GetState() == job.Abort {
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case c.chJobs <- &JobSlot{row: row, Job: j}:
|
||||
default:
|
||||
log.Warn().Msg("unable to put job in workers, trying next cycle")
|
||||
c.updateSlot(row, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// run launches the workers and the ticker.
|
||||
func (c *SchedulerCycle) run() {
|
||||
c.workers()
|
||||
c.tick()
|
||||
}
|
||||
|
||||
// workers launches `MaxWorkers` number of worker to execute job.
|
||||
// If job returns `ErrJobNotCompletedYet`, it re-schedules in the same slot.
|
||||
func (c *SchedulerCycle) workers() {
|
||||
for i := 0; i < MaxWorkers; i++ {
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case j := <-c.chJobs:
|
||||
c.executeJob(j.Job, c.updateCurrentSlot)
|
||||
case <-c.ctx.Done():
|
||||
log.Error().Msg("context done, worker is stopping...")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SchedulerCycle) executeJob(j *job.Job, fnFallBack func(*job.Job)) {
|
||||
j.Run(c.ctx)
|
||||
if j.GetState() == job.Pending {
|
||||
fnFallBack(j)
|
||||
}
|
||||
}
|
||||
|
||||
// tick is a simple ticker incrementing at each scheduler interval,
|
||||
// the slot cursor and dispatch jobs to the workers.
|
||||
func (c *SchedulerCycle) tick() {
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
log.Error().Msg("context done, ticker is stopping...")
|
||||
return
|
||||
default:
|
||||
time.Sleep(c.interval)
|
||||
c.incr()
|
||||
c.dispatch()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
33
internal/scheduler/scheduler_test.go
Normal file
33
internal/scheduler/scheduler_test.go
Normal file
@ -0,0 +1,33 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cycle-scheduler/internal/job"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSlot(t *testing.T) {
|
||||
ctx, fnCancel := context.WithCancel(context.Background())
|
||||
defer fnCancel()
|
||||
|
||||
s := NewSchedulerCycle(ctx, 1*time.Millisecond)
|
||||
|
||||
s.Delay(func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
s.Delay(func(ctx context.Context) error {
|
||||
return job.ErrJobNotCompletedYet
|
||||
})
|
||||
j3 := s.Delay(func(ctx context.Context) error {
|
||||
return errors.New("errors")
|
||||
})
|
||||
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
|
||||
assert.Equal(t, 3, s.Len())
|
||||
assert.Equal(t, job.Failed.String(), s.GetJobDetails(j3).State)
|
||||
}
|
||||
122
main.go
Normal file
122
main.go
Normal file
@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cycle-scheduler/internal/job"
|
||||
"cycle-scheduler/internal/scheduler"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func initLogger() {
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||
log.Logger = log.With().Caller().Logger().Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||
}
|
||||
|
||||
func main() {
|
||||
initLogger()
|
||||
|
||||
ctx, stop := signal.NotifyContext(
|
||||
context.Background(),
|
||||
os.Interrupt,
|
||||
os.Kill,
|
||||
)
|
||||
defer stop()
|
||||
|
||||
interval := 200 * time.Millisecond
|
||||
s := scheduler.NewSchedulerCycle(ctx, interval)
|
||||
s.Display()
|
||||
|
||||
// pending test
|
||||
for i := 0; i < 20; i++ {
|
||||
go func(i int) {
|
||||
time.Sleep(time.Duration(i) * time.Second)
|
||||
s.Delay(func(ctx context.Context) error {
|
||||
time.Sleep(4 * time.Second) //nolint:mnd // test purpose
|
||||
if rand.IntN(10)%2 == 0 { //nolint:gosec,mnd // test prupose
|
||||
return job.ErrJobNotCompletedYet
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
|
||||
// abort test
|
||||
j := s.Delay(func(ctx context.Context) error {
|
||||
time.Sleep(4 * time.Second) //nolint:mnd // test purpose
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
return job.ErrJobNotCompletedYet
|
||||
})
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second) //nolint:mnd // test purpose
|
||||
s.Abort(j)
|
||||
}()
|
||||
|
||||
// abort test 2
|
||||
j2 := s.Delay(func(ctx context.Context) error {
|
||||
time.Sleep(time.Second)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
return job.ErrJobNotCompletedYet
|
||||
})
|
||||
go func() {
|
||||
time.Sleep(10 * time.Second) //nolint:mnd // test purpose
|
||||
s.Abort(j2)
|
||||
}()
|
||||
|
||||
// error test
|
||||
s.Delay(func(ctx context.Context) error {
|
||||
time.Sleep(5 * time.Second) //nolint:mnd // test purpose
|
||||
return errors.New("err")
|
||||
})
|
||||
|
||||
// success test
|
||||
go func() {
|
||||
time.Sleep(10 * time.Second) //nolint:mnd // test purpose
|
||||
s.Delay(func(ctx context.Context) error {
|
||||
time.Sleep(5 * time.Second) //nolint:mnd // test purpose
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(2 * time.Second) //nolint:mnd // test purpose
|
||||
if s.HasAllJobsDone() {
|
||||
s.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
<-s.Done()
|
||||
|
||||
jds := s.GetJobsDetails()
|
||||
for _, jd := range jds {
|
||||
c, err := json.Marshal(&jd)
|
||||
if err != nil {
|
||||
log.Err(err).Str("job", jd.ID.String()).Msg("unable to parse job details into JSON")
|
||||
continue
|
||||
}
|
||||
fmt.Println(string(c))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user