Files
runner/internal/app/poll/poller.go
T
silverwind 6f0d4f1b48 refactor: migrate to connect-go v2 (#1325)
Migrates the runner to `connectrpc.com/connect/v2` by pinning actionslib v1.5.0, which carries https://gitea.com/gitea/actionslib/pulls/28. The wire behaviour towards Gitea is unchanged and works against Gitea servers on connect v1 and v2. The client keeps v1's unlimited response size, and the error codes and messages the runner checks stay the same.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1325
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-10-21 16:10:58 +00:00

435 lines
12 KiB
Go

// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package poll
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"sync"
"sync/atomic"
"time"
"gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/internal/pkg/client"
"gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/metrics"
"connectrpc.com/connect/v2"
runnerv1 "gitea.dev/actionslib/runner/v1"
)
// TaskRunner abstracts task execution so the poller can be tested
// without a real runner.
type TaskRunner interface {
Run(ctx context.Context, task *runnerv1.Task) error
}
// IdleRunner can run maintenance while the poller is idle.
type IdleRunner interface {
OnIdle(ctx context.Context)
}
// AvailabilityRunner can temporarily pause task fetching for local resource
// conditions such as low disk space without changing server-side scheduling.
type AvailabilityRunner interface {
CanAcceptTask(ctx context.Context) (bool, string)
}
type Poller struct {
client client.Client
runner TaskRunner
cfg *config.Config
tasksVersion atomic.Int64 // tasksVersion used to store the version of the last task fetched from the Gitea.
pollingCtx context.Context
shutdownPolling context.CancelFunc
jobsCtx context.Context
shutdownJobs context.CancelFunc
done chan struct{}
registrationRejected atomic.Bool
// unregistered is set when the server rejects the runner with an
// Unauthenticated response, meaning the runner is no longer registered.
unregistered atomic.Bool
lastHealthyPoll atomic.Int64
lastPollFailed atomic.Bool
availabilityMu sync.Mutex
availabilityReady bool
availabilityReason string
}
// workerState holds the single poller's backoff state. Consecutive empty or
// error responses drive exponential backoff; a successful task fetch resets
// both counters so the next poll fires immediately.
type workerState struct {
consecutiveEmpty int64
consecutiveErrors int64
// fetchTimedOut suppresses repeats of the fetch timeout warning.
fetchTimedOut bool
// lastBackoff is the last interval reported to the PollBackoffSeconds gauge;
// used to suppress redundant no-op Set calls when the backoff plateaus
// (e.g. at FetchIntervalMax).
lastBackoff time.Duration
}
func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller {
pollingCtx, shutdownPolling := context.WithCancel(context.Background())
jobsCtx, shutdownJobs := context.WithCancel(context.Background())
done := make(chan struct{})
p := &Poller{
client: client,
runner: runner,
cfg: cfg,
pollingCtx: pollingCtx,
shutdownPolling: shutdownPolling,
jobsCtx: jobsCtx,
shutdownJobs: shutdownJobs,
done: done,
}
p.lastHealthyPoll.Store(time.Now().UnixNano())
p.availabilityReady = true
p.availabilityReason = "ok"
return p
}
func (p *Poller) Poll() {
sem := make(chan struct{}, p.cfg.Runner.Capacity)
wg := &sync.WaitGroup{}
s := &workerState{}
defer func() {
wg.Wait()
close(p.done)
}()
for {
select {
case sem <- struct{}{}:
case <-p.pollingCtx.Done():
return
}
ready, reason := p.localAvailability(p.pollingCtx)
p.reportAvailability(ready, reason)
if !ready {
p.runIdleMaintenance()
<-sem
if !p.waitBackoff(s) {
return
}
continue
}
task, ok := p.fetchTask(p.pollingCtx, s)
if !ok {
p.runIdleMaintenance()
<-sem
if !p.waitBackoff(s) {
return
}
continue
}
s.resetBackoff()
wg.Add(1)
go func(t *runnerv1.Task) {
defer wg.Done()
defer func() { <-sem }()
p.runTaskWithRecover(p.jobsCtx, t)
}(task)
}
}
func (p *Poller) PollOnce(onTaskFetched func()) {
defer close(p.done)
s := &workerState{}
for {
ready, reason := p.localAvailability(p.pollingCtx)
p.reportAvailability(ready, reason)
if !ready {
p.runIdleMaintenance()
if !p.waitBackoff(s) {
return
}
continue
}
task, ok := p.fetchTask(p.pollingCtx, s)
if !ok {
p.runIdleMaintenance()
if !p.waitBackoff(s) {
return
}
continue
}
s.resetBackoff()
onTaskFetched()
p.runTaskWithRecover(p.jobsCtx, task)
return
}
}
// Done returns a channel that is closed once polling has fully stopped,
// allowing callers to react when the poller shuts itself down (e.g. after the
// runner has been unregistered) rather than only on an external cancellation.
func (p *Poller) Done() <-chan struct{} {
return p.done
}
// Unregistered reports whether polling stopped because the server rejected the
// runner as unregistered (an Unauthenticated response).
func (p *Poller) Unregistered() bool {
return p.unregistered.Load()
}
// RegistrationRejected reports whether polling stopped on an error matching client.IsRegistrationRejected.
func (p *Poller) RegistrationRejected() bool {
return p.registrationRejected.Load()
}
// Ready reports whether the daemon can currently communicate with Gitea and
// accept work, reusing the availability the poll loop last observed rather than
// re-running the check. Transient transport failures are tolerated for grace.
func (p *Poller) Ready(grace time.Duration) (bool, string) {
if p.unregistered.Load() {
return false, "runner is no longer registered"
}
if p.pollingCtx.Err() != nil {
return false, "runner is shutting down"
}
p.availabilityMu.Lock()
ready, reason := p.availabilityReady, p.availabilityReason
p.availabilityMu.Unlock()
if !ready {
return false, reason
}
if !p.lastPollFailed.Load() {
return true, "ok"
}
if time.Since(time.Unix(0, p.lastHealthyPoll.Load())) <= grace {
return true, "polling errors within grace period"
}
return false, "unable to poll Gitea"
}
func (p *Poller) localAvailability(ctx context.Context) (bool, string) {
if available, ok := p.runner.(AvailabilityRunner); ok {
return available.CanAcceptTask(ctx)
}
return true, "ok"
}
func (p *Poller) reportAvailability(ready bool, reason string) {
p.availabilityMu.Lock()
defer p.availabilityMu.Unlock()
switch {
case !ready && p.availabilityReady:
logging.Warnf("runner temporarily unavailable: %s", reason)
case ready && !p.availabilityReady:
logging.Info("runner local health recovered, resuming task polling")
}
p.availabilityReady = ready
p.availabilityReason = reason
}
func (p *Poller) runIdleMaintenance() {
if idleRunner, ok := p.runner.(IdleRunner); ok {
idleRunner.OnIdle(p.jobsCtx)
}
}
func (p *Poller) Shutdown(ctx context.Context) error {
p.shutdownPolling()
select {
// graceful shutdown completed succesfully
case <-p.done:
return nil
// our timeout for shutting down ran out
case <-ctx.Done():
// Both the timeout and the graceful shutdown may fire
// simultaneously. Do a non-blocking check to avoid forcing
// a shutdown when graceful already completed.
select {
case <-p.done:
return nil
default:
}
// force a shutdown of all running jobs
p.shutdownJobs()
// wait for running jobs to report their status to Gitea
<-p.done
return ctx.Err()
}
}
func (s *workerState) resetBackoff() {
s.consecutiveEmpty = 0
s.consecutiveErrors = 0
s.lastBackoff = 0
}
// waitBackoff sleeps for the current backoff interval (with jitter).
// Returns false if the polling context was cancelled during the wait.
func (p *Poller) waitBackoff(s *workerState) bool {
base := p.calculateInterval(s)
if base != s.lastBackoff {
metrics.PollBackoffSeconds.Set(base.Seconds())
s.lastBackoff = base
}
timer := time.NewTimer(addJitter(base))
select {
case <-timer.C:
return true
case <-p.pollingCtx.Done():
timer.Stop()
return false
}
}
// calculateInterval returns the polling interval with exponential backoff based on
// consecutive empty or error responses. The interval starts at FetchInterval and
// doubles with each consecutive empty/error, capped at FetchIntervalMax.
func (p *Poller) calculateInterval(s *workerState) time.Duration {
base := p.cfg.Runner.FetchInterval
maxInterval := p.cfg.Runner.FetchIntervalMax
n := max(s.consecutiveEmpty, s.consecutiveErrors)
if n <= 1 {
return base
}
// Capped exponential backoff: base * 2^(n-1), max shift=5 so multiplier <= 32
shift := min(n-1, 5)
interval := base * time.Duration(int64(1)<<shift)
return min(interval, maxInterval)
}
// addJitter adds +/- 20% random jitter to the given duration to avoid thundering herd.
func addJitter(d time.Duration) time.Duration {
if d <= 0 {
return d
}
// jitter range: [-20%, +20%] of d
jitterRange := int64(d) * 2 / 5 // 40% total range
if jitterRange <= 0 {
return d
}
jitter := rand.Int64N(jitterRange) - jitterRange/2
return d + time.Duration(jitter)
}
func (p *Poller) runTaskWithRecover(ctx context.Context, task *runnerv1.Task) {
defer func() {
if r := recover(); r != nil {
err := fmt.Errorf("panic: %v", r)
logging.WithError(err).Error("panic in runTaskWithRecover")
}
}()
if err := p.runner.Run(ctx, task); err != nil {
logging.WithError(err).Error("failed to run task")
}
}
func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, bool) {
reqCtx, cancel := context.WithTimeout(ctx, p.cfg.Runner.FetchTimeout)
defer cancel()
// Load the version value that was in the cache when the request was sent.
v := p.tasksVersion.Load()
start := time.Now()
resp, err := p.client.FetchTask(reqCtx, &runnerv1.FetchTaskRequest{
TasksVersion: v,
})
// Our own deadline proves nothing either way: today Gitea answers immediately,
// so it means a slow server, and once it holds the request open it is an idle
// poll. Back off without claiming the server is healthy, warn once a streak so
// neither case floods, and keep it out of the latency histogram.
if errors.Is(err, context.DeadlineExceeded) {
if !s.fetchTimedOut {
s.fetchTimedOut = true
logging.Warnf("fetching a task timed out after %s, raise runner.fetch_timeout if this persists", p.cfg.Runner.FetchTimeout)
}
s.consecutiveEmpty++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
return nil, false
}
s.fetchTimedOut = false
metrics.PollFetchDuration.Observe(time.Since(start).Seconds())
if err != nil {
// An Unauthenticated response means the server no longer knows this
// runner (e.g. it was deleted). Retrying forever is pointless, so stop
// polling and let the daemon exit with an error instead of spinning.
rejected := client.IsRegistrationRejected(err)
if rejected || connect.CodeOf(err) == connect.CodeUnauthenticated {
logging.WithError(err).Error("server rejected the runner as unregistered, stopping poller")
p.unregistered.Store(true)
p.registrationRejected.Store(rejected)
p.shutdownPolling()
return nil, false
}
// Not a long poll, so a deadline can mean the server assigned a task
// this runner never received.
if errors.Is(err, context.DeadlineExceeded) {
logging.WithError(err).Errorf("fetching a task timed out after %s", p.cfg.Runner.FetchTimeout)
} else {
logging.WithError(err).Error("failed to fetch task")
}
p.lastPollFailed.Store(true)
s.consecutiveErrors++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultError).Inc()
metrics.ClientErrors.WithLabelValues(metrics.LabelMethodFetchTask, connect.CodeOf(err).String()).Inc()
return nil, false
}
p.markHealthyPoll()
// Successful response — reset error counter.
s.consecutiveErrors = 0
if resp == nil {
s.consecutiveEmpty++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
return nil, false
}
if resp.TasksVersion > v {
p.tasksVersion.CompareAndSwap(v, resp.TasksVersion)
}
if resp.Task == nil {
s.consecutiveEmpty++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
return nil, false
}
// got a task, set `tasksVersion` to zero to force query db in next request.
p.tasksVersion.CompareAndSwap(resp.TasksVersion, 0)
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultTask).Inc()
return resp.Task, true
}
func (p *Poller) markHealthyPoll() {
p.lastHealthyPoll.Store(time.Now().UnixNano())
p.lastPollFailed.Store(false)
}