RC-4
release-tag / release-image (push) Successful in 2m7s

This commit is contained in:
2026-08-10 08:17:09 +02:00
parent 2edc941fa9
commit fb4d93165b
9 changed files with 377 additions and 17 deletions
+51
View File
@@ -176,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)
}
}