284 lines
6.8 KiB
Go
284 lines
6.8 KiB
Go
// Package task provides a bounded in-process worker pool and interval jobs.
|
|
// Jobs are not durable and are not coordinated across service replicas.
|
|
package task
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
|
)
|
|
|
|
var ErrClosed = errors.New("task: closed")
|
|
var ErrQueueFull = errors.New("task: queue full")
|
|
|
|
type Func func(context.Context) error
|
|
type Config struct {
|
|
Workers int `json:"workers"`
|
|
QueueSize int `json:"queueSize"`
|
|
MaxSchedules int `json:"maxSchedules"`
|
|
TaskTimeout time.Duration `json:"taskTimeout"`
|
|
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
|
OnError func(string, error) `json:"-"`
|
|
}
|
|
|
|
type Future struct {
|
|
done chan struct{}
|
|
err error
|
|
}
|
|
|
|
func (f *Future) Wait(ctx context.Context) error {
|
|
if ctx == nil {
|
|
return errors.New("task: nil context")
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-f.done:
|
|
return f.err
|
|
}
|
|
}
|
|
func (f *Future) Done() <-chan struct{} { return f.done }
|
|
|
|
type job struct {
|
|
ctx context.Context
|
|
name string
|
|
fn Func
|
|
future *Future
|
|
}
|
|
|
|
type Runner struct {
|
|
config Config
|
|
mu sync.Mutex
|
|
queue chan job
|
|
closed bool
|
|
schedules map[string]struct{}
|
|
stop chan struct{}
|
|
done chan struct{}
|
|
wg sync.WaitGroup
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
active atomic.Int64
|
|
completed atomic.Uint64
|
|
failed atomic.Uint64
|
|
skipped atomic.Uint64
|
|
}
|
|
|
|
type Stats struct {
|
|
Active int64 `json:"active"`
|
|
Queued int `json:"queued"`
|
|
Completed uint64 `json:"completed"`
|
|
Failed uint64 `json:"failed"`
|
|
Skipped uint64 `json:"skipped"`
|
|
}
|
|
|
|
func New(cfg Config) (*Runner, error) {
|
|
if cfg.Workers < 0 || cfg.QueueSize < 0 || cfg.MaxSchedules < 0 || cfg.TaskTimeout < 0 || cfg.ShutdownTimeout < 0 {
|
|
return nil, errors.New("task: invalid configuration")
|
|
}
|
|
if cfg.Workers == 0 {
|
|
cfg.Workers = 4
|
|
}
|
|
if cfg.QueueSize == 0 {
|
|
cfg.QueueSize = 128
|
|
}
|
|
if cfg.MaxSchedules == 0 {
|
|
cfg.MaxSchedules = 128
|
|
}
|
|
if cfg.TaskTimeout == 0 {
|
|
cfg.TaskTimeout = 30 * time.Second
|
|
}
|
|
if cfg.ShutdownTimeout == 0 {
|
|
cfg.ShutdownTimeout = 10 * time.Second
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
r := &Runner{config: cfg, queue: make(chan job, cfg.QueueSize), schedules: make(map[string]struct{}), stop: make(chan struct{}), done: make(chan struct{}), ctx: ctx, cancel: cancel}
|
|
r.wg.Add(cfg.Workers)
|
|
for i := 0; i < cfg.Workers; i++ {
|
|
go r.worker()
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// Submit never blocks on a full queue. The job retains the caller's context;
|
|
// use a service-owned context for work intended to outlive an HTTP request.
|
|
func (r *Runner) Submit(ctx context.Context, name string, fn Func) (*Future, error) {
|
|
if ctx == nil || fn == nil || name == "" || len(name) > 256 {
|
|
return nil, errors.New("task: context, function and bounded name are required")
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.closed {
|
|
return nil, ErrClosed
|
|
}
|
|
f := &Future{done: make(chan struct{})}
|
|
select {
|
|
case r.queue <- job{ctx: ctx, name: name, fn: fn, future: f}:
|
|
return f, nil
|
|
default:
|
|
return nil, ErrQueueFull
|
|
}
|
|
}
|
|
|
|
// Every schedules at a fixed interval, with no initial execution. A tick is
|
|
// skipped if this schedule's previous job is queued/running or the pool is full.
|
|
func (r *Runner) Every(name string, interval time.Duration, fn Func) error {
|
|
if name == "" || len(name) > 256 || interval <= 0 || fn == nil {
|
|
return errors.New("task: invalid schedule")
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.closed {
|
|
return ErrClosed
|
|
}
|
|
if _, ok := r.schedules[name]; ok {
|
|
return errors.New("task: duplicate schedule name")
|
|
}
|
|
if len(r.schedules) >= r.config.MaxSchedules {
|
|
return errors.New("task: schedule capacity reached")
|
|
}
|
|
r.schedules[name] = struct{}{}
|
|
r.wg.Add(1)
|
|
go func() {
|
|
defer r.wg.Done()
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
var previous *Future
|
|
for {
|
|
select {
|
|
case <-r.stop:
|
|
return
|
|
case <-ticker.C:
|
|
if previous != nil {
|
|
select {
|
|
case <-previous.Done():
|
|
previous = nil
|
|
default:
|
|
r.skipped.Add(1)
|
|
continue
|
|
}
|
|
}
|
|
var err error
|
|
previous, err = r.Submit(r.ctx, name, fn)
|
|
if err != nil {
|
|
r.skipped.Add(1)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func (r *Runner) worker() {
|
|
defer r.wg.Done()
|
|
for item := range r.queue {
|
|
r.active.Add(1)
|
|
ctx, cancel := context.WithTimeout(item.ctx, r.config.TaskTimeout)
|
|
stop := context.AfterFunc(r.ctx, cancel)
|
|
if r.ctx.Err() != nil {
|
|
cancel()
|
|
}
|
|
err := run(ctx, item.fn)
|
|
// AfterFunc cancellation may still be queued when the job returns.
|
|
// Check the pool directly before publishing a successful outcome.
|
|
if err == nil {
|
|
err = r.ctx.Err()
|
|
}
|
|
stop()
|
|
cancel()
|
|
if err != nil {
|
|
r.failed.Add(1)
|
|
func() {
|
|
defer func() { _ = recover() }()
|
|
if r.config.OnError != nil {
|
|
r.config.OnError(item.name, err)
|
|
} else {
|
|
logx.ErrorContext(item.ctx, "task.failed", "task", item.name, "error", err)
|
|
}
|
|
}()
|
|
}
|
|
r.active.Add(-1)
|
|
r.completed.Add(1)
|
|
item.future.err = err
|
|
close(item.future.done)
|
|
}
|
|
}
|
|
|
|
func run(ctx context.Context, fn Func) (err error) {
|
|
defer func() {
|
|
if value := recover(); value != nil {
|
|
err = fmt.Errorf("task: panic: %v", value)
|
|
}
|
|
}()
|
|
if err = ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
err = fn(ctx)
|
|
if err == nil {
|
|
err = ctx.Err()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// StopAccepting stops schedules and submission without canceling accepted jobs.
|
|
// Pair it with Wait to establish completion before releasing dependencies.
|
|
func (r *Runner) StopAccepting(ctx context.Context) error {
|
|
if ctx == nil {
|
|
return errors.New("task: nil context")
|
|
}
|
|
r.mu.Lock()
|
|
if !r.closed {
|
|
r.closed = true
|
|
close(r.stop)
|
|
close(r.queue)
|
|
go func() { r.wg.Wait(); r.cancel(); close(r.done) }()
|
|
}
|
|
r.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Wait joins all workers and schedules after StopAccepting. A canceled context
|
|
// cancels work, but callers can wait again to establish actual completion.
|
|
func (r *Runner) Wait(ctx context.Context) error {
|
|
if ctx == nil {
|
|
return errors.New("task: nil context")
|
|
}
|
|
select {
|
|
case <-r.done:
|
|
return nil
|
|
default:
|
|
}
|
|
select {
|
|
case <-r.done:
|
|
return nil
|
|
case <-ctx.Done():
|
|
r.cancel()
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// Shutdown stops admission and waits with the caller's deadline.
|
|
func (r *Runner) Shutdown(ctx context.Context) error {
|
|
if err := r.StopAccepting(ctx); err != nil {
|
|
return err
|
|
}
|
|
return r.Wait(ctx)
|
|
}
|
|
|
|
func (r *Runner) Close() error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), r.config.ShutdownTimeout)
|
|
defer cancel()
|
|
return r.Shutdown(ctx)
|
|
}
|
|
|
|
func (r *Runner) Stats() Stats {
|
|
return Stats{Active: r.active.Load(), Queued: len(r.queue), Completed: r.completed.Load(), Failed: r.failed.Load(), Skipped: r.skipped.Load()}
|
|
}
|