This commit is contained in:
2026-08-10 16:20:59 +02:00
parent 005fd6ca51
commit bb19bbd30e
19 changed files with 2491 additions and 33 deletions
+66
View File
@@ -100,6 +100,21 @@ func (s *State) ConnectedCount() int64 {
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 == "" {
@@ -161,6 +176,57 @@ type AcceptResult struct {
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()
+23
View File
@@ -79,3 +79,26 @@ func TestGuessHotPathAndReroll(t *testing.T) {
t.Fatalf("reroll not reset: %+v", g)
}
}
func TestLotterySkipConsumesSequenceWithoutCountingAcceptedGuess(t *testing.T) {
s := New()
task := data.Task{ID: "lottery-task", PublicSeed: "seed", Revision: 1}
s.InitGuess(task, "client", GuessState{})
if err := s.CanSubmit(task, "client", 0, 0); err != nil {
t.Fatalf("eligible guess rejected: %v", err)
}
next, err := s.SkipLottery(task, "client", 0, 0)
if err != nil {
t.Fatal(err)
}
if next != 1 {
t.Fatalf("next seq = %d, want 1", next)
}
g, ok := s.Current(task, "client")
if !ok {
t.Fatal("missing runtime state")
}
if g.NextSeq != 1 || g.GuessCount != 0 || g.LastGuess.IsZero() {
t.Fatalf("unexpected lottery skip state: %+v", g)
}
}