feat: initial commit
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
package scoreboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FetchFunc retrieves a fresh set of scoreboard entries.
|
||||
type FetchFunc func(ctx context.Context) ([]Entry, error)
|
||||
|
||||
// Refresher periodically refreshes a Store from a FetchFunc, keeping the last
|
||||
// good snapshot when a refresh fails.
|
||||
type Refresher struct {
|
||||
store *Store
|
||||
fetch FetchFunc
|
||||
interval time.Duration
|
||||
logger *slog.Logger
|
||||
|
||||
lastErr atomic.Pointer[refreshError]
|
||||
successN atomic.Int64
|
||||
failureN atomic.Int64
|
||||
}
|
||||
|
||||
type refreshError struct {
|
||||
At time.Time
|
||||
Err error
|
||||
}
|
||||
|
||||
// NewRefresher wires a refresher. interval must be > 0.
|
||||
func NewRefresher(store *Store, fetch FetchFunc, interval time.Duration, logger *slog.Logger) *Refresher {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Refresher{store: store, fetch: fetch, interval: interval, logger: logger}
|
||||
}
|
||||
|
||||
// RefreshOnce performs a single refresh, updating the store on success.
|
||||
func (r *Refresher) RefreshOnce(ctx context.Context) error {
|
||||
start := time.Now()
|
||||
entries, err := r.fetch(ctx)
|
||||
if err != nil {
|
||||
r.failureN.Add(1)
|
||||
r.lastErr.Store(&refreshError{At: time.Now(), Err: err})
|
||||
r.logger.Error("scoreboard refresh failed", "err", err, "serving_stale", r.store.Ready())
|
||||
return err
|
||||
}
|
||||
r.store.Set(entries, time.Now())
|
||||
r.successN.Add(1)
|
||||
r.logger.Info("scoreboard refreshed", "entries", len(entries), "took", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run blocks, refreshing on each tick until ctx is cancelled. It does NOT
|
||||
// refresh immediately; call RefreshOnce first if you want a warm start.
|
||||
func (r *Refresher) Run(ctx context.Context) {
|
||||
t := time.NewTicker(r.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
r.logger.Info("scoreboard refresher stopping")
|
||||
return
|
||||
case <-t.C:
|
||||
_ = r.RefreshOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats reports refresh counters and the last error (nil if none) for /metrics
|
||||
// or health output.
|
||||
func (r *Refresher) Stats() (success, failure int64, lastErr error, lastErrAt time.Time) {
|
||||
if e := r.lastErr.Load(); e != nil {
|
||||
lastErr, lastErrAt = e.Err, e.At
|
||||
}
|
||||
return r.successN.Load(), r.failureN.Load(), lastErr, lastErrAt
|
||||
}
|
||||
Reference in New Issue
Block a user