package userbase import ( "errors" "time" "github.com/go-git/go-git/v5/plumbing" "tug/internal/platform/replica" ) const ( pollInterval = 5 * time.Second quietPeriod = 30 * time.Second pendingCap = 5 * time.Minute dailyInterval = 24 * time.Hour retryFirst = 30 * time.Second retryMax = 15 * time.Minute retryJitter = 0.2 recheckInterval = time.Hour pruneInterval = 24 * time.Hour ) type action int const ( actWait action = iota actPlan actStep actRecheck ) type refusal struct { kind string head plumbing.Hash } type schedule struct { jitter func() float64 isPlanned bool stepNow bool known bool committed [32]byte dailyDue time.Time seenSet bool seen [32]byte quietSince time.Time dirtySince time.Time retry time.Time backoff time.Duration movedOnce bool refusing bool refusalAt refusal audited map[refusal]bool lastPrune time.Time } func newSchedule(jitter func() float64) *schedule { return &schedule{jitter: jitter, audited: map[refusal]bool{}} } func (s *schedule) pending() bool { return !s.known || s.seen != s.committed } func (s *schedule) settle(now time.Time) { switch { case !s.seenSet || !s.pending(): s.dirtySince = time.Time{} case s.dirtySince.IsZero(): s.dirtySince = now } } func (s *schedule) observe(now time.Time, digest [32]byte) { if !s.seenSet || digest != s.seen { s.seen, s.seenSet, s.quietSince = digest, true, now } s.settle(now) } func (s *schedule) planned(now time.Time, plan replica.StartPlan) { s.isPlanned = true s.stepNow = plan.StepNow s.known = !plan.StepNow && !plan.DailyDue.IsZero() s.committed = plan.Committed s.dailyDue = plan.DailyDue s.clearFailures() s.settle(now) } func (s *schedule) clearFailures() { s.retry = time.Time{} s.backoff = 0 s.movedOnce = false s.refusing = false } func (s *schedule) next(now time.Time) action { switch { case now.Before(s.retry): return actWait case s.refusing: return actRecheck case !s.isPlanned: return actPlan case s.stepNow: return actStep case s.seenSet && s.pending() && (now.Sub(s.quietSince) >= quietPeriod || now.Sub(s.dirtySince) >= pendingCap): return actStep case s.known && !now.Before(s.dailyDue): return actStep } return actWait } func (s *schedule) succeeded(now time.Time, current, committed [32]byte, commitTime time.Time) { s.clearFailures() s.stepNow = false s.known = true s.committed = committed s.dailyDue = commitTime.Add(dailyInterval) if !s.seenSet || current != s.seen { s.seen, s.seenSet, s.quietSince = current, true, now } s.settle(now) } func (s *schedule) failed(now time.Time, err error) { if errors.Is(err, replica.ErrRemoteMoved) && !s.movedOnce { s.movedOnce = true s.retry = now return } if !errors.Is(err, replica.ErrRemoteMoved) { s.movedOnce = false } s.backoff = min(max(2*s.backoff, retryFirst), retryMax) s.retry = now.Add(time.Duration(float64(s.backoff) * (1 + retryJitter*s.jitter()))) } func (s *schedule) refused(now time.Time, r refusal) bool { s.backoff = 0 s.movedOnce = false s.refusing = true s.refusalAt = r s.retry = now.Add(recheckInterval) if s.audited[r] { return false } s.audited[r] = true return true } func (s *schedule) unchanged(now time.Time) { s.backoff = 0 s.retry = now.Add(recheckInterval) } func (s *schedule) currentRefusal() (refusal, bool) { return s.refusalAt, s.refusing } func (s *schedule) retryAt() time.Time { return s.retry } func (s *schedule) pruneDue(now time.Time, res replica.Result) bool { if res.Outcome != replica.Pushed || res.MirrorErr != nil { return false } return s.lastPrune.IsZero() || now.Sub(s.lastPrune) >= pruneInterval } func (s *schedule) pruned(now time.Time) { s.lastPrune = now }