This commit is contained in:
285
internal/runtime/state.go
Normal file
285
internal/runtime/state.go
Normal file
@@ -0,0 +1,285 @@
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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())}
|
||||
}
|
||||
81
internal/runtime/state_test.go
Normal file
81
internal/runtime/state_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/data"
|
||||
)
|
||||
|
||||
func TestPresenceSingleSession(t *testing.T) {
|
||||
s := New()
|
||||
lease1, err := s.AcquirePresence("c", "s1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.AcquirePresence("c", "s2"); err == nil {
|
||||
t.Fatal("expected conflict")
|
||||
}
|
||||
if !s.HasPresence("c", "s1") {
|
||||
t.Fatal("missing presence")
|
||||
}
|
||||
s.ReleasePresence("c", "s1", lease1)
|
||||
if s.ConnectedCount() != 0 {
|
||||
t.Fatal("presence not released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenceReconnectOldReleaseCannotDeleteNewLease(t *testing.T) {
|
||||
s := New()
|
||||
oldLease, err := s.AcquirePresence("c", "same-session")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newLease, err := s.AcquirePresence("c", "same-session")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if oldLease == newLease {
|
||||
t.Fatal("expected a new lease generation")
|
||||
}
|
||||
|
||||
// This is the race that caused periodic HTTP 409 responses: the old
|
||||
// websocket's defer ran after the replacement websocket had connected.
|
||||
s.ReleasePresence("c", "same-session", oldLease)
|
||||
if !s.HasPresence("c", "same-session") {
|
||||
t.Fatal("stale websocket release removed the replacement presence")
|
||||
}
|
||||
|
||||
s.ReleasePresence("c", "same-session", newLease)
|
||||
if s.ConnectedCount() != 0 {
|
||||
t.Fatal("new lease was not released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessHotPathAndReroll(t *testing.T) {
|
||||
s := New()
|
||||
task := data.Task{ID: "t", PublicSeed: "a", Revision: 1}
|
||||
s.InitGuess(task, "c", GuessState{})
|
||||
r, err := s.Accept(task, "c", 0, 12.5, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !r.Improved || r.State.NextSeq != 1 || r.State.GuessCount != 1 {
|
||||
t.Fatalf("bad state: %+v", r)
|
||||
}
|
||||
if _, err = s.Accept(task, "c", 1, 10, time.Hour); err != ErrRateLimited {
|
||||
t.Fatalf("want rate limit, got %v", err)
|
||||
}
|
||||
// Same seed/admin revision preserves sequence.
|
||||
task.Revision = 2
|
||||
g := s.InitGuess(task, "c", GuessState{})
|
||||
if g.NextSeq != 1 {
|
||||
t.Fatalf("sequence lost: %d", g.NextSeq)
|
||||
}
|
||||
// New seed is a reroll and reloads the persisted reset.
|
||||
task.PublicSeed = "b"
|
||||
g = s.InitGuess(task, "c", GuessState{})
|
||||
if g.NextSeq != 0 || g.BestScore != 0 {
|
||||
t.Fatalf("reroll not reset: %+v", g)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user