feat: initial commit
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
// Package scoreboard holds the root-me scoreboard domain type and the
|
||||
// in-memory store/refresher that serve it to the transports.
|
||||
package scoreboard
|
||||
|
||||
// Entry is a single row of the root-me scoreboard.
|
||||
//
|
||||
// Rank is the canonical 1-based position in score-descending order, assigned by
|
||||
// the scraper. (root-me's own displayed rank is noisy around ties, so we don't
|
||||
// rely on it.)
|
||||
type Entry struct {
|
||||
Rank int `json:"rank"`
|
||||
Username string `json:"username"`
|
||||
// ProfilePath is the site-relative profile path, e.g. "/skav".
|
||||
ProfilePath string `json:"profile_path,omitempty"`
|
||||
// Country is the two-letter country code from the flag icon, e.g. "fr".
|
||||
Country string `json:"country,omitempty"`
|
||||
// Grade is the root-me rank/grade name, e.g. "legend".
|
||||
Grade string `json:"grade,omitempty"`
|
||||
Score int `json:"score"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package scoreboard
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Snapshot is an immutable view of the scoreboard at a point in time.
|
||||
type Snapshot struct {
|
||||
Entries []Entry
|
||||
FetchedAt time.Time
|
||||
byRank map[int]Entry
|
||||
byUser map[string]Entry // lower-cased username -> entry
|
||||
}
|
||||
|
||||
func newSnapshot(entries []Entry, at time.Time) *Snapshot {
|
||||
s := &Snapshot{
|
||||
Entries: entries,
|
||||
FetchedAt: at,
|
||||
byRank: make(map[int]Entry, len(entries)),
|
||||
byUser: make(map[string]Entry, len(entries)),
|
||||
}
|
||||
for _, e := range entries {
|
||||
s.byRank[e.Rank] = e
|
||||
s.byUser[strings.ToLower(e.Username)] = e
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Store holds the latest scoreboard snapshot and serves concurrent reads.
|
||||
// It keeps the last good snapshot when a refresh fails.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
snap *Snapshot
|
||||
}
|
||||
|
||||
// NewStore returns an empty store. Ready reports false until Set is called.
|
||||
func NewStore() *Store { return &Store{} }
|
||||
|
||||
// Set atomically replaces the current snapshot with the given entries.
|
||||
func (s *Store) Set(entries []Entry, at time.Time) {
|
||||
snap := newSnapshot(entries, at)
|
||||
s.mu.Lock()
|
||||
s.snap = snap
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Ready reports whether a snapshot has been loaded.
|
||||
func (s *Store) Ready() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.snap != nil
|
||||
}
|
||||
|
||||
// Snapshot returns the current snapshot (nil if none loaded yet).
|
||||
func (s *Store) Snapshot() *Snapshot {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.snap
|
||||
}
|
||||
|
||||
// List returns a page of entries ordered by rank, honoring offset and limit.
|
||||
// limit <= 0 means "all from offset". It also returns the snapshot time.
|
||||
func (s *Store) List(offset, limit int) ([]Entry, time.Time) {
|
||||
snap := s.Snapshot()
|
||||
if snap == nil {
|
||||
return nil, time.Time{}
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(snap.Entries) {
|
||||
return []Entry{}, snap.FetchedAt
|
||||
}
|
||||
end := len(snap.Entries)
|
||||
if limit > 0 && offset+limit < end {
|
||||
end = offset + limit
|
||||
}
|
||||
out := make([]Entry, end-offset)
|
||||
copy(out, snap.Entries[offset:end])
|
||||
return out, snap.FetchedAt
|
||||
}
|
||||
|
||||
// ByRank returns the entry at the given canonical rank.
|
||||
func (s *Store) ByRank(rank int) (Entry, bool) {
|
||||
snap := s.Snapshot()
|
||||
if snap == nil {
|
||||
return Entry{}, false
|
||||
}
|
||||
e, ok := snap.byRank[rank]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// ByUsername returns the entry for the given username (case-insensitive).
|
||||
func (s *Store) ByUsername(username string) (Entry, bool) {
|
||||
snap := s.Snapshot()
|
||||
if snap == nil {
|
||||
return Entry{}, false
|
||||
}
|
||||
e, ok := snap.byUser[strings.ToLower(strings.TrimSpace(username))]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// Len returns the number of entries in the current snapshot.
|
||||
func (s *Store) Len() int {
|
||||
snap := s.Snapshot()
|
||||
if snap == nil {
|
||||
return 0
|
||||
}
|
||||
return len(snap.Entries)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package scoreboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sample() []Entry {
|
||||
return []Entry{
|
||||
{Rank: 1, Username: "skav", Score: 26435},
|
||||
{Rank: 2, Username: "Kkameleon", Score: 26000},
|
||||
{Rank: 3, Username: "ENOENT", Score: 25900},
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreLookups(t *testing.T) {
|
||||
s := NewStore()
|
||||
if s.Ready() {
|
||||
t.Fatal("empty store should not be ready")
|
||||
}
|
||||
s.Set(sample(), time.Now())
|
||||
if !s.Ready() || s.Len() != 3 {
|
||||
t.Fatalf("ready=%v len=%d", s.Ready(), s.Len())
|
||||
}
|
||||
|
||||
if e, ok := s.ByRank(2); !ok || e.Username != "Kkameleon" {
|
||||
t.Errorf("ByRank(2) = %+v ok=%v", e, ok)
|
||||
}
|
||||
if e, ok := s.ByUsername("SKAV"); !ok || e.Rank != 1 {
|
||||
t.Errorf("ByUsername(SKAV) = %+v ok=%v (case-insensitive lookup failed)", e, ok)
|
||||
}
|
||||
if _, ok := s.ByUsername("nobody"); ok {
|
||||
t.Error("ByUsername(nobody) should miss")
|
||||
}
|
||||
|
||||
page, _ := s.List(1, 1)
|
||||
if len(page) != 1 || page[0].Username != "Kkameleon" {
|
||||
t.Errorf("List(1,1) = %+v", page)
|
||||
}
|
||||
if page, _ := s.List(10, 5); len(page) != 0 {
|
||||
t.Errorf("List past end should be empty, got %+v", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresherKeepsLastGood(t *testing.T) {
|
||||
s := NewStore()
|
||||
calls := 0
|
||||
fetch := func(context.Context) ([]Entry, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return sample(), nil
|
||||
}
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
r := NewRefresher(s, fetch, time.Hour, nil)
|
||||
|
||||
if err := r.RefreshOnce(context.Background()); err != nil {
|
||||
t.Fatalf("first refresh: %v", err)
|
||||
}
|
||||
if err := r.RefreshOnce(context.Background()); err == nil {
|
||||
t.Fatal("second refresh should have failed")
|
||||
}
|
||||
// Last good snapshot must still be served.
|
||||
if s.Len() != 3 {
|
||||
t.Errorf("expected last good snapshot of 3, got %d", s.Len())
|
||||
}
|
||||
ok, fail, lastErr, _ := r.Stats()
|
||||
if ok != 1 || fail != 1 || lastErr == nil {
|
||||
t.Errorf("stats: ok=%d fail=%d lastErr=%v", ok, fail, lastErr)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user