Evict expired candidate offers on a timer
CI / ci (push) Successful in 2m47s
CI / ci (pull_request) Successful in 2m51s

Each dinner's offered candidates now carry a time.AfterFunc expiry that
drops them from the service once offerLifetime passes, so token-bearing
candidate URLs are no longer held until some later search sweeps them.
Storing new offers or clearing a dinner's offers stops the old timer; the
timer drops only the set it was armed for. The expiry runs no goroutine
until it fires and touches only the in-memory map.

TestCandidatesExpire now waits for the eviction with no further search
and checks the candidate no longer grounds; TestExpiredCandidatesNotHeld
checks no dinner's expired offers remain held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 22:50:47 -04:00
co-authored by Claude Opus 5.5
parent 515919088a
commit bef3f3fd5c
4 changed files with 77 additions and 29 deletions
+32 -9
View File
@@ -928,24 +928,47 @@ func TestCandidatesRunningWhenCleared(t *testing.T) {
}
}
// TestCandidatesExpire: candidates ground a dinner only for their
// lifetime; storing another dinner's candidates drops the expired ones.
// TestCandidatesExpire: once their lifetime passes, a dinner's candidates
// are dropped and no longer ground it, with no other search running.
func TestCandidatesExpire(t *testing.T) {
dinners.SetOfferLifetime(t, 0)
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
other := failedDinner(t, e, "Other curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
e.search.Results["Other curry"] = []recipes.Candidate{{URL: curryB}}
cid := candidateID(t, e, id, "Chicken Curry")
waitNoOffers(t, e)
wantStaleCandidate(t, e, id, cid)
if !e.svc.HasOffers(id) {
t.Fatalf("candidates of %s not held before another search", id)
}
// TestExpiredCandidatesNotHeld: the service holds no dinner's candidates
// past their lifetime, however many dinners were offered some.
func TestExpiredCandidatesNotHeld(t *testing.T) {
dinners.SetOfferLifetime(t, 50*time.Millisecond)
e := newEnv(t)
names := map[string]string{"Mystery curry": curryA, "Other curry": curryB}
ids := make([]string, 0, len(names))
for name, u := range names {
ids = append(ids, failedDinner(t, e, name))
e.search.Results[name] = []recipes.Candidate{{URL: u}}
}
candidateID(t, e, other, "Easy Weeknight Curry")
if e.svc.HasOffers(id) {
t.Errorf("expired candidates of %s still held after another dinner's search", id)
for _, id := range ids {
if _, err := e.svc.Candidates(t.Context(), id); err != nil {
t.Fatal(err)
}
}
waitNoOffers(t, e)
}
// waitNoOffers waits for the service to hold no dinner's candidates.
func waitNoOffers(t *testing.T, e *env) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for n := e.svc.OfferCount(); n > 0; n = e.svc.OfferCount() {
if time.Now().After(deadline) {
t.Fatalf("service still holds the expired candidates of %d dinners", n)
}
time.Sleep(5 * time.Millisecond)
}
}