package taskscheduler import ( "context" "errors" "fmt" "sync/atomic" "time" "golang.org/x/sync/errgroup" runnerv1 "gitea.dev/actionslib/runner/v1" ) type PollClient interface { Declare(context.Context, string, []string) error FetchTask(context.Context, int64) (*runnerv1.FetchTaskResponse, error) } type PollerConfig struct { Version string Labels []string EmptyBackoff time.Duration ErrorBackoff time.Duration Capacity int } // Poller is the scheduler component. Each fetcher keeps its assigned task // until that assignment is durably dispatched; all fetchers share one runner // declaration and a monotonic tasks version. type Poller struct { Client PollClient Scheduler *Scheduler Config PollerConfig OnError func(error) } func (p Poller) Run(ctx context.Context) error { if p.Client == nil || p.Scheduler == nil { return errors.New("Gitea poll client and task scheduler are required") } if p.Config.Version == "" || len(p.Config.Labels) == 0 { return errors.New("runner version and labels are required") } if err := p.Client.Declare(ctx, p.Config.Version, p.Config.Labels); err != nil { return fmt.Errorf("declare scheduler labels: %w", err) } emptyBackoff := p.Config.EmptyBackoff if emptyBackoff <= 0 { emptyBackoff = time.Second } errorBackoff := p.Config.ErrorBackoff if errorBackoff <= 0 { errorBackoff = 5 * time.Second } capacity := p.Config.Capacity if capacity < 1 { capacity = 1 } var tasksVersion atomic.Int64 group, groupContext := errgroup.WithContext(ctx) for range capacity { group.Go(func() error { return p.runFetcher(groupContext, &tasksVersion, emptyBackoff, errorBackoff) }) } return group.Wait() } func (p Poller) runFetcher(ctx context.Context, tasksVersion *atomic.Int64, emptyBackoff, errorBackoff time.Duration) error { for { response, err := p.Client.FetchTask(ctx, tasksVersion.Load()) if err != nil { if ctx.Err() != nil { return nil } p.report(fmt.Errorf("fetch Gitea task: %w", err)) if !wait(ctx, errorBackoff) { return nil } continue } if response == nil { p.report(errors.New("fetch Gitea task returned an empty response")) if !wait(ctx, errorBackoff) { return nil } continue } storeMaximum(tasksVersion, response.GetTasksVersion()) task := response.GetTask() if task == nil { if !wait(ctx, emptyBackoff) { return nil } continue } for { if err := p.Scheduler.Run(ctx, task); err == nil { break } else { if ctx.Err() != nil { return nil } p.report(fmt.Errorf("dispatch Gitea task %d: %w", task.GetId(), err)) } if !wait(ctx, errorBackoff) { return nil } } } } func storeMaximum(value *atomic.Int64, candidate int64) { for current := value.Load(); candidate > current; current = value.Load() { if value.CompareAndSwap(current, candidate) { return } } } func (p Poller) report(err error) { if p.OnError != nil { p.OnError(err) } } func wait(ctx context.Context, duration time.Duration) bool { timer := time.NewTimer(duration) defer timer.Stop() select { case <-ctx.Done(): return false case <-timer.C: return true } }