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 }