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()