Files

165 lines
3.3 KiB
Go

package refreshcoord
import (
"strings"
"time"
)
type Snapshot struct {
UpdatedAt string
Stale bool
RefreshInProgress bool
LastError string
NextRetryAt string
}
// Coordinator is a small reusable SWR-like state machine:
// stale-while-refresh + single-flight + exponential backoff after failures.
//
// All methods are intentionally lock-free; caller owns synchronization.
type Coordinator struct {
freshTTL time.Duration
backoffMin time.Duration
backoffMax time.Duration
updatedAt time.Time
lastError string
refreshInProgress bool
consecutiveErrors int
nextRetryAt time.Time
}
func New(freshTTL, backoffMin, backoffMax time.Duration) Coordinator {
if freshTTL <= 0 {
freshTTL = 10 * time.Minute
}
if backoffMin <= 0 {
backoffMin = 2 * time.Second
}
if backoffMax <= 0 {
backoffMax = 60 * time.Second
}
if backoffMax < backoffMin {
backoffMax = backoffMin
}
return Coordinator{
freshTTL: freshTTL,
backoffMin: backoffMin,
backoffMax: backoffMax,
}
}
func (c *Coordinator) SetUpdatedAt(at time.Time) {
c.updatedAt = at
}
func (c *Coordinator) BeginRefresh(now time.Time, force bool, hasData bool) bool {
if !c.ShouldRefresh(now, force, hasData) {
return false
}
c.refreshInProgress = true
return true
}
func (c *Coordinator) ShouldRefresh(now time.Time, force bool, hasData bool) bool {
if c.refreshInProgress {
return false
}
if !c.nextRetryAt.IsZero() && now.Before(c.nextRetryAt) {
return false
}
if force {
return true
}
if !hasData {
return true
}
return c.IsStale(now)
}
func (c *Coordinator) IsStale(now time.Time) bool {
if c.updatedAt.IsZero() {
return true
}
return now.Sub(c.updatedAt) > c.freshTTL
}
func (c *Coordinator) FinishSuccess(now time.Time) {
c.updatedAt = now
c.lastError = ""
c.refreshInProgress = false
c.consecutiveErrors = 0
c.nextRetryAt = time.Time{}
}
func (c *Coordinator) FinishError(msg string, now time.Time) {
c.lastError = strings.TrimSpace(msg)
c.refreshInProgress = false
c.consecutiveErrors++
c.nextRetryAt = now.Add(c.nextBackoff())
}
func (c *Coordinator) Snapshot(now time.Time) Snapshot {
out := Snapshot{
Stale: c.IsStale(now),
RefreshInProgress: c.refreshInProgress,
LastError: strings.TrimSpace(c.lastError),
}
if !c.updatedAt.IsZero() {
out.UpdatedAt = c.updatedAt.UTC().Format(time.RFC3339)
}
if !c.nextRetryAt.IsZero() {
out.NextRetryAt = c.nextRetryAt.UTC().Format(time.RFC3339)
}
return out
}
func (c *Coordinator) RefreshInProgress() bool {
return c.refreshInProgress
}
func (c *Coordinator) NextRetryAt() time.Time {
return c.nextRetryAt
}
func (c *Coordinator) ConsecutiveErrors() int {
return c.consecutiveErrors
}
func (c *Coordinator) LastError() string {
return c.lastError
}
func (c *Coordinator) ClearBackoff() {
c.nextRetryAt = time.Time{}
}
func (c *Coordinator) nextBackoff() time.Duration {
backoff := c.backoffMin
if backoff <= 0 {
backoff = 2 * time.Second
}
maxBackoff := c.backoffMax
if maxBackoff <= 0 {
maxBackoff = backoff
}
if maxBackoff < backoff {
maxBackoff = backoff
}
for i := 1; i < c.consecutiveErrors; i++ {
if backoff >= maxBackoff {
return maxBackoff
}
if backoff > maxBackoff/2 {
return maxBackoff
}
backoff *= 2
}
if backoff > maxBackoff {
backoff = maxBackoff
}
return backoff
}