165 lines
3.3 KiB
Go
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
|
|
}
|