Files
2026-08-10 16:20:59 +02:00

352 lines
9.3 KiB
Go

package runtime
import (
"errors"
"sync"
"sync/atomic"
"time"
"neuralhunt/internal/data"
)
var (
ErrPresenceConflict = errors.New("identity already connected")
ErrRateLimited = errors.New("rate limited")
ErrBadSequence = errors.New("bad sequence")
)
type guessKey struct{ TaskID, ClientID string }
type presenceEntry struct {
SessionID string
LeaseID uint64
}
type GuessState struct {
NextSeq int64
LastGuess time.Time
BestScore float64
GuessCount int64
PublicSeed string
Revision int64
}
type State struct {
mu sync.RWMutex
presence map[string]presenceEntry
selected map[string]string
guesses map[guessKey]GuessState
started time.Time
presenceSeq atomic.Uint64
guessesTotal atomic.Uint64
improvementsTotal atomic.Uint64
sqliteWritesTotal atomic.Uint64
rejectedTotal atomic.Uint64
rateMu sync.Mutex
sec [60]rateBucket
}
type rateBucket struct {
unix int64
guesses uint64
improvements uint64
sqliteWrites uint64
rejected uint64
}
func New() *State {
return &State{presence: make(map[string]presenceEntry), selected: make(map[string]string), guesses: make(map[guessKey]GuessState), started: time.Now()}
}
// AcquirePresence reserves a client identity for one authenticated browser/session.
// Reconnects from the SAME session are allowed and receive a new lease generation.
// The generation is critical: an old websocket may finish closing after a replacement
// connection has already been established. Its deferred ReleasePresence must not delete
// the newer connection's presence entry.
func (s *State) AcquirePresence(clientID, sessionID string) (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
if cur, ok := s.presence[clientID]; ok && cur.SessionID != sessionID {
return 0, ErrPresenceConflict
}
lease := s.presenceSeq.Add(1)
s.presence[clientID] = presenceEntry{SessionID: sessionID, LeaseID: lease}
return lease, nil
}
func (s *State) HasPresence(clientID, sessionID string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
cur, ok := s.presence[clientID]
return ok && cur.SessionID == sessionID
}
func (s *State) ReleasePresence(clientID, sessionID string, leaseID uint64) {
s.mu.Lock()
if cur, ok := s.presence[clientID]; ok && cur.SessionID == sessionID && cur.LeaseID == leaseID {
delete(s.presence, clientID)
}
s.mu.Unlock()
}
func (s *State) IsConnected(clientID string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.presence[clientID]
return ok
}
func (s *State) ConnectedCount() int64 {
s.mu.RLock()
defer s.mu.RUnlock()
return int64(len(s.presence))
}
// ForgetClient removes non-durable runtime state for an identity that was
// deleted by the admin profile cleanup tool. The caller must only pass clients
// that are currently disconnected.
func (s *State) ForgetClient(clientID string) {
s.mu.Lock()
delete(s.presence, clientID)
delete(s.selected, clientID)
for k := range s.guesses {
if k.ClientID == clientID {
delete(s.guesses, k)
}
}
s.mu.Unlock()
}
func (s *State) SetTaskSelection(clientID, taskID string) {
s.mu.Lock()
if taskID == "" {
delete(s.selected, clientID)
} else {
s.selected[clientID] = taskID
}
s.mu.Unlock()
}
func (s *State) TaskSelection(clientID string) string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.selected[clientID]
}
func (s *State) ReplaceTaskSelection(oldTaskID, newTaskID string) {
s.mu.Lock()
for cid, taskID := range s.selected {
if taskID == oldTaskID {
s.selected[cid] = newTaskID
}
}
s.mu.Unlock()
}
func (s *State) InitGuess(task data.Task, cid string, persisted GuessState) GuessState {
k := guessKey{task.ID, cid}
s.mu.Lock()
defer s.mu.Unlock()
if cur, ok := s.guesses[k]; ok {
// Preserve the hot-path sequence across admin changes that keep the same seed.
// A reroll changes public_seed, which intentionally reloads the persisted reset.
if cur.PublicSeed == task.PublicSeed {
cur.Revision = task.Revision
s.guesses[k] = cur
return cur
}
}
persisted.PublicSeed = task.PublicSeed
persisted.Revision = task.Revision
s.guesses[k] = persisted
return persisted
}
func (s *State) Current(task data.Task, cid string) (GuessState, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
g, ok := s.guesses[guessKey{task.ID, cid}]
if !ok || g.PublicSeed != task.PublicSeed {
return GuessState{}, false
}
return g, true
}
type AcceptResult struct {
State GuessState
Previous GuessState
Improved bool
}
// CanSubmit validates sequence and per-client timing before a request is
// admitted into the global-per-task lottery. It does not mutate state, so an
// unselected ticket can be consumed explicitly by SkipLottery.
func (s *State) CanSubmit(task data.Task, cid string, seq int64, minInterval time.Duration) error {
k := guessKey{task.ID, cid}
now := time.Now().UTC()
s.mu.RLock()
g, ok := s.guesses[k]
s.mu.RUnlock()
if !ok || g.PublicSeed != task.PublicSeed || seq != g.NextSeq {
s.record(false, false, true)
return ErrBadSequence
}
if !g.LastGuess.IsZero() && minInterval > 0 && now.Sub(g.LastGuess) < minInterval {
s.record(false, false, true)
return ErrRateLimited
}
return nil
}
// SkipLottery consumes a valid sequence that lost the random draw without
// evaluating/scoring it. This prevents the same deterministic guess from being
// resubmitted forever while keeping guess_count reserved for actually accepted
// and evaluated tips.
func (s *State) SkipLottery(task data.Task, cid string, seq int64, minInterval time.Duration) (int64, error) {
k := guessKey{task.ID, cid}
now := time.Now().UTC()
s.mu.Lock()
g, ok := s.guesses[k]
if !ok || g.PublicSeed != task.PublicSeed || seq != g.NextSeq {
next := g.NextSeq
s.mu.Unlock()
s.record(false, false, true)
return next, ErrBadSequence
}
if !g.LastGuess.IsZero() && minInterval > 0 && now.Sub(g.LastGuess) < minInterval {
next := g.NextSeq
s.mu.Unlock()
s.record(false, false, true)
return next, ErrRateLimited
}
g.NextSeq++
g.LastGuess = now
g.Revision = task.Revision
s.guesses[k] = g
next := g.NextSeq
s.mu.Unlock()
s.record(false, false, true)
return next, nil
}
func (s *State) Accept(task data.Task, cid string, seq int64, score float64, minInterval time.Duration) (AcceptResult, error) {
k := guessKey{task.ID, cid}
now := time.Now().UTC()
s.mu.Lock()
g, ok := s.guesses[k]
if !ok || g.PublicSeed != task.PublicSeed {
s.mu.Unlock()
return AcceptResult{}, ErrBadSequence
}
if seq != g.NextSeq {
s.mu.Unlock()
s.record(false, false, true)
return AcceptResult{}, ErrBadSequence
}
prev := g
if !g.LastGuess.IsZero() && minInterval > 0 && now.Sub(g.LastGuess) < minInterval {
s.mu.Unlock()
s.record(false, false, true)
return AcceptResult{}, ErrRateLimited
}
g.NextSeq++
g.GuessCount++
g.LastGuess = now
improved := score > g.BestScore
if improved {
g.BestScore = score
}
g.Revision = task.Revision
s.guesses[k] = g
s.mu.Unlock()
s.record(true, improved, false)
return AcceptResult{State: g, Previous: prev, Improved: improved}, nil
}
func (s *State) Restore(task data.Task, cid string, acceptedNextSeq int64, previous GuessState) {
k := guessKey{task.ID, cid}
s.mu.Lock()
if cur, ok := s.guesses[k]; ok && cur.NextSeq == acceptedNextSeq && cur.PublicSeed == task.PublicSeed {
s.guesses[k] = previous
}
s.mu.Unlock()
}
func (s *State) MarkSQLiteWrite() {
s.sqliteWritesTotal.Add(1)
s.rateMu.Lock()
b := s.bucketLocked(time.Now().Unix())
b.sqliteWrites++
s.rateMu.Unlock()
}
func (s *State) record(guess, improvement, rejected bool) {
if guess {
s.guessesTotal.Add(1)
}
if improvement {
s.improvementsTotal.Add(1)
}
if rejected {
s.rejectedTotal.Add(1)
}
s.rateMu.Lock()
b := s.bucketLocked(time.Now().Unix())
if guess {
b.guesses++
}
if improvement {
b.improvements++
}
if rejected {
b.rejected++
}
s.rateMu.Unlock()
}
func (s *State) bucketLocked(sec int64) *rateBucket {
i := sec % 60
if s.sec[i].unix != sec {
s.sec[i] = rateBucket{unix: sec}
}
return &s.sec[i]
}
func (s *State) Rates(window int64) (guesses, improvements, sqliteWrites, rejected float64) {
if window < 1 {
window = 5
}
if window > 60 {
window = 60
}
now := time.Now().Unix()
var g, i, w, r uint64
s.rateMu.Lock()
for x := range s.sec {
b := s.sec[x]
if b.unix > now-window {
g += b.guesses
i += b.improvements
w += b.sqliteWrites
r += b.rejected
}
}
s.rateMu.Unlock()
d := float64(window)
return float64(g) / d, float64(i) / d, float64(w) / d, float64(r) / d
}
type Metrics struct {
Connected int64 `json:"connected"`
GuessesPerSec float64 `json:"guesses_per_sec"`
ImprovementsPerSec float64 `json:"improvements_per_sec"`
SQLiteWritesPerSec float64 `json:"sqlite_writes_per_sec"`
RejectedPerSec float64 `json:"rejected_per_sec"`
GuessesTotal uint64 `json:"guesses_total"`
ImprovementsTotal uint64 `json:"improvements_total"`
SQLiteWritesTotal uint64 `json:"sqlite_writes_total"`
RejectedTotal uint64 `json:"rejected_total"`
UptimeSec int64 `json:"uptime_sec"`
}
func (s *State) Metrics() Metrics {
g, i, w, r := s.Rates(5)
return Metrics{Connected: s.ConnectedCount(), GuessesPerSec: g, ImprovementsPerSec: i, SQLiteWritesPerSec: w, RejectedPerSec: r, GuessesTotal: s.guessesTotal.Load(), ImprovementsTotal: s.improvementsTotal.Load(), SQLiteWritesTotal: s.sqliteWritesTotal.Load(), RejectedTotal: s.rejectedTotal.Load(), UptimeSec: int64(time.Since(s.started).Seconds())}
}