RC-5
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user