Refuse expired candidate offers at lookup time
CI / ci (push) Successful in 2m39s
CI / ci (pull_request) Successful in 2m25s

Store each offer set's deadline and check it under offersMu in
candidateURL, so an expired candidate id cannot ground a dinner while its
eviction timer is delayed. The timer still evicts the set from memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 22:58:43 -04:00
co-authored by Claude Opus 5.5
parent bef3f3fd5c
commit 471daec594
3 changed files with 38 additions and 7 deletions
+9
View File
@@ -22,6 +22,15 @@ func SetOfferLifetime(t *testing.T, d time.Duration) {
t.Cleanup(func() { offerLifetime = old })
}
// AdvanceClock moves the service's clock d ahead of real time until t
// ends, without firing any timer. A test calling it must not run in
// parallel.
func AdvanceClock(t *testing.T, d time.Duration) {
old := now
now = func() time.Time { return old().Add(d) }
t.Cleanup(func() { now = old })
}
// HasOffers reports whether the service holds candidates offered for
// dinner id.
func (s *Service) HasOffers(id string) bool {
+12 -7
View File
@@ -300,12 +300,16 @@ type Candidate struct {
// ground it; tests shorten it.
var offerLifetime = 30 * time.Minute
// now is the clock offer lifetimes are measured on; tests advance it.
var now = time.Now
// offerSet is the candidates last offered for a dinner: the page URL of
// each candidate id. expiry drops the set from offers once offerLifetime
// has passed.
// each candidate id. They ground the dinner until deadline; expiry drops
// the set from offers once it has passed.
type offerSet struct {
urls map[string]string
expiry *time.Timer
urls map[string]string
deadline time.Time
expiry *time.Timer
}
// offerSearch is a Candidates search running for a dinner. It is stale
@@ -368,7 +372,7 @@ func (s *Service) trackOffers(id string) (keep func(urls map[string]string), don
return
}
s.dropOffers(id)
set := &offerSet{urls: urls}
set := &offerSet{urls: urls, deadline: now().Add(offerLifetime)}
// expiry waits for offersMu, so it finds set stored even when it
// fires at once, and drops set only, never offers stored later.
set.expiry = time.AfterFunc(offerLifetime, func() {
@@ -423,12 +427,13 @@ func (s *Service) candidateID(id, rawURL string) string {
// candidateURL is the page URL of candidate cid last offered for dinner id.
// A cid not among the dinner's live offers, or whose page is not a valid
// recipe link, is a *ValidationError.
// recipe link, is a *ValidationError. Offers past their deadline are not
// live, even before their expiry drops them.
func (s *Service) candidateURL(id, cid string) (string, error) {
var raw string
var ok bool
s.offersMu.Lock()
if set := s.offers[id]; set != nil {
if set := s.offers[id]; set != nil && now().Before(set.deadline) {
raw, ok = set.urls[cid]
}
s.offersMu.Unlock()
+17
View File
@@ -941,6 +941,23 @@ func TestCandidatesExpire(t *testing.T) {
wantStaleCandidate(t, e, id, cid)
}
// TestCandidatesExpireBeforeEviction: once their lifetime passes, a
// dinner's candidates no longer ground it, even while the service still
// holds them because their eviction has not run yet.
func TestCandidatesExpireBeforeEviction(t *testing.T) {
dinners.SetOfferLifetime(t, time.Hour)
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
cid := candidateID(t, e, id, "Chicken Curry")
dinners.AdvanceClock(t, time.Hour)
if !e.svc.HasOffers(id) {
t.Fatal("candidates evicted already, want them held until their timer fires")
}
wantStaleCandidate(t, e, id, cid)
}
// TestExpiredCandidatesNotHeld: the service holds no dinner's candidates
// past their lifetime, however many dinners were offered some.
func TestExpiredCandidatesNotHeld(t *testing.T) {