feat: initial commit
build / test (push) Successful in 9s
build / build (push) Successful in 10s
build / build-image (push) Successful in 1m4s

This commit is contained in:
2026-06-04 14:19:37 +02:00
commit cf961ab94f
28 changed files with 3036 additions and 0 deletions
+78
View File
@@ -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
}