Merge remote-tracking branch 'origin/dev' into feature/e3a8e4ea-8dcb-49e8-878f-552a6af109cb-discover-side-quest-suggestions
CI / ci (pull_request) Successful in 2m43s
CI / ci (push) Successful in 2m54s

This commit is contained in:
2026-10-07 04:46:22 -04:00
25 changed files with 1126 additions and 91 deletions
+36 -10
View File
@@ -7,6 +7,7 @@ package dinners
import (
"context"
"crypto/rand"
"database/sql"
"errors"
"fmt"
@@ -142,6 +143,18 @@ type Service struct {
// by dinner id, so archiving or renaming a dinner can stop them.
groundingsMu sync.Mutex
groundings map[string]map[*runningGrounding]struct{}
// offerKey keys the candidate ids Candidates offers, so they are
// opaque and stable for this process.
offerKey []byte
// offersMu guards offers and offerSearches. offers holds, by dinner
// id, the candidates last offered by Candidates until they expire, so
// a candidate id is resolved here and its URL never leaves the server.
// offerSearches holds the Candidates searches running, by dinner id,
// so clearing a dinner's offers keeps them from storing stale ones.
offersMu sync.Mutex
offers map[string]*offerSet
offerSearches map[string]map[*offerSearch]struct{}
}
// NewService returns a service over st that grounds dinners with grounder
@@ -158,7 +171,8 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
case logger == nil:
return nil, errors.New("dinners: nil logger")
}
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{})}
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
rand.Read(s.offerKey) // never fails: it crashes the program instead
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
if err != nil {
return nil, err
@@ -324,11 +338,14 @@ func (s *Service) listQuestLog(ctx context.Context) (rows []db.Dinner, err error
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
// *ValidationError and a new name repeating another unarchived dinner a
// *DuplicateError; either changes nothing. Renaming a dinner still pending
// clears its recipe_url, raises its grounding_revision and enqueues a fresh
// KindGroundDinner job that grounds the new name: a job queued or running
// for the old name or link carries the old revision, so it can no longer
// write the dinner, and one running is stopped.
// *DuplicateError; either changes nothing. Renaming a dinner raises its
// grounding_revision and drops the candidates offered for its old name:
// each was offered at an earlier revision, so none of them grounds it.
// Renaming a dinner still pending also enqueues a fresh KindGroundDinner
// job that grounds the new name, and clears its recipe_url when the
// normalized name changes: a job queued or running for the old name or
// link carries the old revision, so it can no longer write the dinner, and
// one running is stopped.
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
@@ -339,7 +356,7 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
}
var row db.Dinner
var regrounding bool
var renamed, regrounding bool
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetDinner(ctx, id)
@@ -373,11 +390,13 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
}
params.Name = name
params.NormalizedName = harness.Normalize(name)
if name != cur.Name {
params.GroundingRevision++
}
if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName {
// The link was the old dinner's recipe; the new name is
// grounded by name, and a link can be given by hand.
params.RecipeUrl = sql.NullString{}
params.GroundingRevision++
}
}
if p.Method != nil {
@@ -393,12 +412,17 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
if err != nil {
return fmt.Errorf("dinners: update %s: %w", id, err)
}
regrounding = row.GroundingRevision != cur.GroundingRevision
renamed = row.GroundingRevision != cur.GroundingRevision
regrounding = renamed && cur.GroundingStatus == GroundingPending
return nil
})
if err != nil {
return Dinner{}, err
}
if renamed {
updateCommitted()
s.clearOffers(row.ID, row.GroundingRevision)
}
if regrounding {
s.stopGroundings(row.ID, row.GroundingRevision)
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
@@ -417,7 +441,8 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
// Archive sets dinner id's status to archived, recording updatedBy. The row
// is kept, so its name still counts for Side quest dedupe. A
// KindGroundDinner job running for it is stopped.
// KindGroundDinner job running for it is stopped, and its candidates no
// longer ground it.
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
@@ -434,6 +459,7 @@ func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, er
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
}
s.stopGroundings(row.ID, math.MaxInt64)
s.clearOffers(row.ID, math.MaxInt64)
return fromRow(row), nil
}
+4 -2
View File
@@ -16,8 +16,10 @@ import (
func TestCreateRegularNeverNamesFromLink(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
const link = "https://c.example/stew-key-abcdefghijklmno?sig=zyxwvut#frag"
leaks := []string{"c.example", "key", "abcdefghijklmno", "sig", "zyxwvut", "frag"}
const link = "https://c.example/stew-key-abcdefghijklmno?sig=zyxwvut#lookup"
// Each leak holds a character no ULID has (Crockford base32 has no I, L,
// O, U, "-", "." or "/"), so a dinner's id never matches one.
leaks := []string{link, "c.example", "stew-key", "abcdefghijklmno", "sig", "zyxwvut", "lookup"}
check := func(what, text string) {
t.Helper()
lower := strings.ToLower(text)
+50
View File
@@ -14,6 +14,56 @@ func SetGroundTimeout(t *testing.T, d time.Duration) {
t.Cleanup(func() { groundTimeout = old })
}
// SetOfferLifetime sets how long candidates ground a dinner until t ends.
// A test calling it must not run in parallel.
func SetOfferLifetime(t *testing.T, d time.Duration) {
old := offerLifetime
offerLifetime = d
t.Cleanup(func() { offerLifetime = old })
}
// SetUpdateCommitted runs f in Update once a rename is committed, before
// the dinner's offers are cleared, until t ends. A test calling it must
// not run in parallel.
func SetUpdateCommitted(t *testing.T, f func()) {
old := updateCommitted
updateCommitted = f
t.Cleanup(func() { updateCommitted = 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 {
s.offersMu.Lock()
defer s.offersMu.Unlock()
_, ok := s.offers[id]
return ok
}
// StoreOffers stores urls as dinner id's offers, as a Candidates search
// that read the dinner at grounding_revision revision would on finishing.
func (s *Service) StoreOffers(id string, urls map[string]string, revision int64) {
keep, done := s.trackOffers(id)
defer done()
keep(urls, revision)
}
// OfferCount is the number of dinners whose candidates the service holds.
func (s *Service) OfferCount() int {
s.offersMu.Lock()
defer s.offersMu.Unlock()
return len(s.offers)
}
// RunGroundJob runs the KindGroundDinner handler for p in the caller's
// goroutine, as the job runner would, and returns the grounding status it
// reports.
+183 -14
View File
@@ -2,10 +2,14 @@ package dinners
import (
"context"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math"
"slices"
"strings"
"time"
@@ -81,6 +85,11 @@ var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
// logged.
var ErrGroundFailed = errors.New("dinners: grounding the recipe failed")
// ErrCandidatesFailed wraps any unexpected failure finding a dinner's
// candidates. Its text may quote a candidate's URL and dependency errors,
// so it is never logged.
var ErrCandidatesFailed = errors.New("dinners: finding recipe candidates failed")
// ErrArchived is grounding an archived dinner by hand.
var ErrArchived = errors.New("dinner archived")
@@ -282,7 +291,9 @@ func reasonFor(err error, fromURL bool) string {
// Candidate is a recipe page offered to ground a dinner in.
type Candidate struct {
// CandidateID is the page's URL; Fix.CandidateID grounds it.
// CandidateID is an opaque id for the page, resolved by the server:
// Fix.CandidateID grounds it. The page's URL is never returned, as it
// may carry a token.
CandidateID string `json:"candidate_id"`
Title string `json:"title"`
SiteName string `json:"site_name"`
@@ -291,29 +302,182 @@ type Candidate struct {
Minutes *int `json:"minutes"`
}
// offerLifetime is how long the candidates Candidates offers a dinner
// 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
// updateCommitted runs in Update once a rename is committed, before the
// dinner's offers are cleared; tests use it to act in between.
var updateCommitted = func() {}
// offerSet is the candidates last offered for a dinner: the page URL of
// each candidate id, found by searching the dinner's name at its
// grounding_revision revision. They ground the dinner until deadline, and
// only while it is still at that revision, which every rename raises;
// expiry drops the set from offers once deadline has passed.
type offerSet struct {
urls map[string]string
revision int64
deadline time.Time
expiry *time.Timer
}
// offerSearch is a Candidates search running for a dinner. It stores no
// offers found at a grounding_revision below below, which clearing the
// dinner's offers raises.
type offerSearch struct {
below int64
}
// Candidates searches for dinner id's name and returns up to MaxCandidates
// recipe pages to ground it in, best first; the list may be empty. A
// search that fails wraps recipes.ErrSearchFailed.
// recipe pages to ground it in, best first; the list may be empty. They
// replace the dinner's earlier candidates: one not offered again no longer
// grounds it. They ground it for offerLifetime, and not at all once it is
// grounded by hand, renamed or archived, even while this search runs. A
// missing dinner is ErrNotFound; any other failure wraps
// ErrCandidatesFailed, and a search that fails also wraps
// recipes.ErrSearchFailed.
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
// Tracked before the dinner is read: grounding, renaming or archiving
// it after the read keeps this search from storing what it found.
keep, done := s.trackOffers(id)
defer done()
row, err := s.get(ctx, id)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, err
}
if err != nil {
return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err)
}
offers, err := s.grounder.Candidates(ctx, row.Name, row.Name, MaxCandidates)
if err != nil {
return nil, err
return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err)
}
out := make([]Candidate, len(offers))
urls := make(map[string]string, len(offers))
for i, o := range offers {
out[i] = Candidate{CandidateID: o.URL, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
cid := s.candidateID(row.ID, o.URL)
urls[cid] = o.URL
out[i] = Candidate{CandidateID: cid, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
if o.PhotoID != "" {
u := PhotoURL(o.PhotoID)
out[i].PhotoURL = &u
}
}
if row.Status != StatusArchived {
keep(urls, row.GroundingRevision)
}
return out, nil
}
// trackOffers registers a Candidates search for dinner id. It returns keep,
// which stores urls, found for the dinner at grounding_revision revision,
// as its offers for offerLifetime unless clearOffers has run for it since
// with a revision above that, or offers found at a later revision are
// stored, and done, to call when the search ends.
func (s *Service) trackOffers(id string) (keep func(urls map[string]string, revision int64), done func()) {
search := &offerSearch{}
s.offersMu.Lock()
if s.offerSearches[id] == nil {
s.offerSearches[id] = make(map[*offerSearch]struct{})
}
s.offerSearches[id][search] = struct{}{}
s.offersMu.Unlock()
keep = func(urls map[string]string, revision int64) {
s.offersMu.Lock()
defer s.offersMu.Unlock()
if revision < search.below {
return
}
if set := s.offers[id]; set != nil && set.revision > revision {
// Found at a later revision, so the earlier search's offers
// are already stale.
return
}
s.dropOffers(id)
set := &offerSet{urls: urls, revision: revision, 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() {
s.offersMu.Lock()
defer s.offersMu.Unlock()
if s.offers[id] == set {
delete(s.offers, id)
}
})
s.offers[id] = set
}
done = func() {
s.offersMu.Lock()
delete(s.offerSearches[id], search)
if len(s.offerSearches[id]) == 0 {
delete(s.offerSearches, id)
}
s.offersMu.Unlock()
}
return keep, done
}
// clearOffers drops dinner id's offers found at a grounding_revision below
// below and keeps every Candidates search running for it from storing
// such offers, so none of them grounds it. Offers found at below or above
// are kept. Call it with math.MaxInt64 once the dinner is grounded by hand
// or archived, and with its new revision once it is renamed: a search
// that read the rename meanwhile keeps its offers.
func (s *Service) clearOffers(id string, below int64) {
s.offersMu.Lock()
defer s.offersMu.Unlock()
if set := s.offers[id]; set != nil && set.revision < below {
s.dropOffers(id)
}
for search := range s.offerSearches[id] {
search.below = max(search.below, below)
}
}
// dropOffers drops dinner id's offers and stops their expiry. The caller
// holds offersMu.
func (s *Service) dropOffers(id string) {
if set := s.offers[id]; set != nil {
set.expiry.Stop()
delete(s.offers, id)
}
}
// candidateID is the opaque id of the page at rawURL as a candidate for
// dinner id: the same page is offered under the same id until the service
// restarts, and the id reveals nothing of the URL.
func (s *Service) candidateID(id, rawURL string) string {
mac := hmac.New(sha256.New, s.offerKey)
mac.Write([]byte(id + "\x00" + rawURL))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16])
}
// candidateURL is the page URL of candidate cid last offered for dinner d.
// A cid not among the dinner's live offers, or whose page is not a valid
// recipe link, is a *ValidationError. Offers past their deadline, or found
// at a grounding_revision other than d's, are not live, even before they
// are dropped: d was renamed since they were found, if only to its name
// then.
func (s *Service) candidateURL(d db.Dinner, cid string) (string, error) {
var raw string
var ok bool
s.offersMu.Lock()
if set := s.offers[d.ID]; set != nil && set.revision == d.GroundingRevision && now().Before(set.deadline) {
raw, ok = set.urls[cid]
}
s.offersMu.Unlock()
if ok {
if u, err := checkRecipeURL(raw); err == nil {
return u.String(), nil
}
}
return "", invalid("candidate_id", "is not a candidate from recipe-candidates; reload the candidates")
}
// Fix grounds a dinner by hand: exactly one of a candidate from
// Candidates, a pasted recipe link or a recipe typed in.
type Fix struct {
@@ -367,6 +531,11 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if cur.Status == StatusArchived {
return Dinner{}, ErrArchived
}
if f.CandidateID != nil {
if link, err = s.candidateURL(cur, *f.CandidateID); err != nil {
return Dinner{}, err
}
}
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
defer cancel()
gr, err := s.grounder.GroundURL(groundCtx, link)
@@ -401,6 +570,7 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
}
s.clearOffers(cur.ID, math.MaxInt64)
return fromRow(row), nil
}
@@ -422,9 +592,10 @@ func (s *Service) groundRejected(ctx context.Context, id string) error {
}
// groundManual stores m as dinner id's recipe, with its ingredients and
// steps, and records it on the dinner, all in one transaction. A missing
// or archived dinner is ErrNotFound or ErrArchived; any other failure
// wraps ErrGroundFailed.
// steps, and records it on the dinner, all in one transaction; the
// dinner's candidates then no longer ground it. A missing or archived
// dinner is ErrNotFound or ErrArchived; any other failure wraps
// ErrGroundFailed.
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
var row db.Dinner
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -522,6 +693,7 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
s.clearOffers(row.ID, math.MaxInt64)
return fromRow(row), nil
}
@@ -550,11 +722,8 @@ func (f Fix) validate() (link string, manual *ManualRecipe, err error) {
}
switch {
case f.CandidateID != nil:
u, err := checkRecipeURL(*f.CandidateID)
if err != nil {
return "", nil, invalid("candidate_id", "is not a candidate from recipe-candidates")
}
return u.String(), nil, nil
// Ground resolves the candidate to its page.
return "", nil, nil
case f.RecipeURL != nil:
u, err := checkRecipeURL(*f.RecipeURL)
if err != nil {
+511 -8
View File
@@ -9,6 +9,7 @@ import (
"io/fs"
"log/slog"
"slices"
"strings"
"testing"
"testing/fstest"
"time"
@@ -214,6 +215,39 @@ func TestCreateByRecipeURLGroundsThatURL(t *testing.T) {
}
}
// TestCreateByRecipeURLClearsLink: once background grounding resolves a
// pasted link, which may carry a personal token, the dinner no longer
// stores it; the recipe it grounded in records the page.
func TestCreateByRecipeURLClearsLink(t *testing.T) {
t.Parallel()
e := newEnv(t)
const link = "https://c.example/recipes/beef-stew?access_token=personal-token-abc123"
e.pages.Bodies[link] = recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Beef stew"), RecipeURL: ptr(link)}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
row := e.dinner(t, c.ID)
if row.GroundingStatus != "grounded" || !row.RecipeID.Valid {
t.Fatalf("dinner = %+v, want grounded", row)
}
if row.RecipeUrl.Valid {
t.Errorf("recipe_url = %q after grounding, want cleared", row.RecipeUrl.String)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != link {
t.Errorf("recipe source = %q, want %q", rec.SourceUrl, link)
}
}
func TestCreateGroundingFailureRecordsReason(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -642,6 +676,26 @@ func failedDinner(t *testing.T, e *env, name string) string {
return c.ID
}
// candidateID returns the candidate_id Candidates offers dinner id for the
// page titled title.
func candidateID(t *testing.T, e *env, id, title string) string {
t.Helper()
got, err := e.svc.Candidates(t.Context(), id)
if err != nil {
t.Fatal(err)
}
for _, c := range got {
if c.Title == title {
if strings.Contains(c.CandidateID, "://") {
t.Fatalf("candidate_id = %q, want an opaque id, not the page URL", c.CandidateID)
}
return c.CandidateID
}
}
t.Fatalf("candidates = %+v, want one titled %q", got, title)
return ""
}
func TestCandidates(t *testing.T) {
t.Parallel()
e := newEnv(t)
@@ -664,14 +718,25 @@ func TestCandidates(t *testing.T) {
}
first, second := got[0], got[1]
wantPhoto := dinners.PhotoURL(e.dinner(t, c.ID).PhotoID.String)
if first.CandidateID != curryA || first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
if first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
first.PhotoURL == nil || *first.PhotoURL != wantPhoto || first.Minutes == nil || *first.Minutes != 40 {
t.Errorf("first = %+v, want the stored curryA with photo %s", first, wantPhoto)
}
if second.CandidateID != curryB || second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
if second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
second.PhotoURL != nil || second.Minutes == nil || *second.Minutes != 25 {
t.Errorf("second = %+v, want curryB without a photo", second)
}
if first.CandidateID == "" || first.CandidateID == second.CandidateID ||
first.CandidateID == curryA || second.CandidateID == curryB {
t.Errorf("candidate ids = %q, %q, want distinct opaque ids", first.CandidateID, second.CandidateID)
}
again, err := e.svc.Candidates(t.Context(), c.ID)
if err != nil {
t.Fatal(err)
}
if len(again) != 2 || again[0].CandidateID != first.CandidateID || again[1].CandidateID != second.CandidateID {
t.Errorf("candidates again = %+v, want the same ids", again)
}
if _, err := e.svc.Candidates(t.Context(), "nope"); !errors.Is(err, dinners.ErrNotFound) {
t.Errorf("unknown dinner: err = %v, want ErrNotFound", err)
@@ -702,9 +767,11 @@ func TestFixViaCandidate(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}, {URL: curryB}}
cid := candidateID(t, e, id, "Easy Weeknight Curry")
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(curryB)}, "member-2")
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(cid)}, "member-2")
if err != nil {
t.Fatal(err)
}
@@ -721,6 +788,435 @@ func TestFixViaCandidate(t *testing.T) {
}
}
// wantStaleCandidate checks that candidate cid no longer grounds dinner
// id: Ground refuses it as invalid, and the dinner is unchanged.
func wantStaleCandidate(t *testing.T, e *env, id, cid string) {
t.Helper()
before := e.dinner(t, id)
_, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(cid)}, member)
var invalid *dinners.ValidationError
if !errors.As(err, &invalid) {
t.Fatalf("stale candidate: err = %v, want *ValidationError", err)
}
if after := e.dinner(t, id); after != before {
t.Errorf("dinner changed by a stale candidate: %+v, was %+v", after, before)
}
}
// TestGroundingOrArchivingClearsCandidates: once a dinner is grounded by
// hand — by candidate, link or manual recipe — renamed or archived, the
// service holds none of its candidates' URLs and none of them grounds it.
func TestGroundingOrArchivingClearsCandidates(t *testing.T) {
t.Parallel()
tests := []struct {
name string
// clear grounds or archives dinner id, offered candidate cid.
clear func(t *testing.T, e *env, id, cid string)
// archived says the dinner ends archived, which Ground refuses
// before it reads a candidate.
archived bool
}{
{"candidate", func(t *testing.T, e *env, id, cid string) {
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(cid)}, member); err != nil {
t.Fatal(err)
}
}, false},
{"link", func(t *testing.T, e *env, id, _ string) {
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(stew)}, member); err != nil {
t.Fatal(err)
}
}, false},
{"manual", func(t *testing.T, e *env, id, _ string) {
m := &dinners.ManualRecipe{Ingredients: []string{"1 chicken"}, Minutes: ptr(40), Method: ptr("stovetop")}
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: m}, member); err != nil {
t.Fatal(err)
}
}, false},
{"rename", func(t *testing.T, e *env, id, _ string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
}, false},
{"archive", func(t *testing.T, e *env, id, _ string) {
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err)
}
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}, {URL: curryB}}
cidA := candidateID(t, e, id, "Chicken Curry")
cidB := candidateID(t, e, id, "Easy Weeknight Curry")
tt.clear(t, e, id, cidA)
if e.svc.HasOffers(id) {
t.Errorf("service still holds the candidates of dinner %s", id)
}
if !tt.archived {
wantStaleCandidate(t, e, id, cidB)
}
})
}
}
// TestRenamePendingRefusesOldCandidates: candidates offered for a pending
// dinner's old name no longer ground it once it is renamed — Ground refuses
// them and leaves the old recipe off it — while candidates for the new name
// do.
func TestRenamePendingRefusesOldCandidates(t *testing.T) {
t.Parallel()
// Unstarted, so the dinner stays pending with its jobs queued.
e := newUnstartedEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
old := candidateID(t, e, c.ID, "Chicken Curry")
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != dinners.GroundingPending {
t.Fatalf("renamed = %+v, want still pending", d)
}
if e.svc.HasOffers(c.ID) {
t.Errorf("service still holds the old name's candidates of dinner %s", c.ID)
}
wantStaleCandidate(t, e, c.ID, old)
if row := e.dinner(t, c.ID); row.RecipeID.Valid || row.GroundingStatus != dinners.GroundingPending {
t.Fatalf("dinner = %+v, want pending with no recipe", row)
}
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
g, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{CandidateID: ptr(candidateID(t, e, c.ID, "Beef Stew"))}, member)
if err != nil {
t.Fatal(err)
}
if g.GroundingStatus != dinners.GroundingGrounded || g.RecipeID == nil {
t.Fatalf("dinner = %+v, want grounded for the new name", g)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *g.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != stew {
t.Errorf("recipe source = %s, want %s", rec.SourceUrl, stew)
}
}
// TestRenameSameNormalizedNameRefusesOldCandidates: a rename that leaves
// the normalized name as it was still drops the candidates offered before
// it, and none of them grounds the dinner.
func TestRenameSameNormalizedNameRefusesOldCandidates(t *testing.T) {
t.Parallel()
// Unstarted, so the dinner stays pending with its jobs queued.
e := newUnstartedEnv(t)
e.search.Results["Quick Chicken curry"] = e.search.Results["Chicken curry"]
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
old := candidateID(t, e, c.ID, "Chicken Curry")
before := e.dinner(t, c.ID)
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Quick Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if e.svc.HasOffers(c.ID) {
t.Errorf("service still holds the candidates offered before dinner %s was renamed", c.ID)
}
// A reply is queued, so only refusing the candidate keeps its recipe off.
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
wantStaleCandidate(t, e, c.ID, old)
row := e.dinner(t, c.ID)
if row.RecipeID.Valid {
t.Fatalf("dinner = %+v, want no recipe", row)
}
if row.NormalizedName != before.NormalizedName {
t.Fatalf("normalized name = %q, want %q unchanged", row.NormalizedName, before.NormalizedName)
}
// Still pending, now at the rename's revision, with a job for it.
if d.GroundingStatus != dinners.GroundingPending || row.GroundingRevision != before.GroundingRevision+1 {
t.Fatalf("renamed = %+v, want pending at revision %d", row, before.GroundingRevision+1)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(),
`SELECT count(*) FROM jobs WHERE status = 'queued' AND json_extract(payload, '$.revision') = ?`,
row.GroundingRevision).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Fatalf("queued jobs at revision %d = %d, want 1", row.GroundingRevision, queued)
}
}
// TestCandidatesBetweenRenameAndClearKept: a candidate search that reads
// a dinner after its rename commits, and stores its candidates before the
// rename clears the dinner's offers, keeps them: they ground the renamed
// dinner, while one offered before the rename does not, even when a search
// that read the dinner before the rename finishes after them.
func TestCandidatesBetweenRenameAndClearKept(t *testing.T) {
// Unstarted, so the dinner stays pending with its jobs queued.
e := newUnstartedEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
old := candidateID(t, e, c.ID, "Chicken Curry")
before := e.dinner(t, c.ID)
var fresh string
dinners.SetUpdateCommitted(t, func() {
fresh = candidateID(t, e, c.ID, "Beef Stew")
e.svc.StoreOffers(c.ID, map[string]string{old: curryA}, before.GroundingRevision)
})
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
if fresh == "" {
t.Fatal("no candidate search ran between the rename and clearing offers")
}
if !e.svc.HasOffers(c.ID) {
t.Fatalf("clearing offers after the rename dropped the candidates found at its revision for dinner %s", c.ID)
}
wantStaleCandidate(t, e, c.ID, old)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
g, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{CandidateID: ptr(fresh)}, member)
if err != nil {
t.Fatal(err)
}
if g.GroundingStatus != dinners.GroundingGrounded || g.RecipeID == nil {
t.Fatalf("dinner = %+v, want grounded for the new name", g)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *g.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != stew {
t.Errorf("recipe source = %s, want %s", rec.SourceUrl, stew)
}
}
// TestRenameBackDuringCandidatesRefusesThem: a candidate search that read
// a dinner as A and finishes after renames A -> B -> A have committed, but
// before either has cleared the dinner's offers, stores candidates that
// do not ground it, though its name is A again.
func TestRenameBackDuringCandidatesRefusesThem(t *testing.T) {
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
asked := len(e.search.Queries())
gate := make(chan struct{})
e.search.Gate = gate
type result struct {
got []dinners.Candidate
err error
}
done := make(chan result, 1)
go func() {
got, err := e.svc.Candidates(t.Context(), id)
done <- result{got, err}
}()
deadline := time.Now().Add(10 * time.Second)
for len(e.search.Queries()) == asked {
if time.Now().After(deadline) {
t.Fatal("candidate search never started")
}
time.Sleep(5 * time.Millisecond)
}
// The first rename's hook makes the second; the second's lets the
// search finish and tries its candidate, before either clears offers.
var hooks int
var stale error
var groundedBy *dinners.Dinner
dinners.SetUpdateCommitted(t, func() {
hooks++
switch hooks {
case 1:
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Mystery curry")}, member); err != nil {
t.Error(err)
}
case 2:
close(gate)
r := <-done
if r.err != nil || len(r.got) != 1 {
t.Errorf("candidates = %+v, %v; want curryA", r.got, r.err)
return
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(r.got[0].CandidateID)}, member)
if err == nil {
groundedBy = &d
}
stale = err
}
})
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
if hooks != 2 {
t.Fatalf("renames committed = %d, want 2", hooks)
}
var invalid *dinners.ValidationError
if !errors.As(stale, &invalid) {
t.Fatalf("stale candidate: err = %v, grounded %+v; want *ValidationError", stale, groundedBy)
}
row := e.dinner(t, id)
if row.Name != "Mystery curry" || row.RecipeID.Valid || row.GroundingStatus != dinners.GroundingFailed {
t.Errorf("dinner = %+v, want Mystery curry still failed with no recipe", row)
}
if e.svc.HasOffers(id) {
t.Errorf("service still holds the stale candidates of dinner %s", id)
}
}
// TestCandidatesRunningWhenCleared: a candidate search still running when
// its dinner is grounded by hand, renamed or archived stores none of the
// candidates it returns, so none of them grounds the dinner.
func TestCandidatesRunningWhenCleared(t *testing.T) {
t.Parallel()
tests := []struct {
name string
clear func(t *testing.T, e *env, id string)
// archived says the dinner ends archived.
archived bool
}{
{"manual", func(t *testing.T, e *env, id string) {
m := &dinners.ManualRecipe{Ingredients: []string{"1 chicken"}, Minutes: ptr(40), Method: ptr("stovetop")}
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: m}, member); err != nil {
t.Fatal(err)
}
}, false},
{"rename", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
}, false},
{"archive", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err)
}
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
asked := len(e.search.Queries())
gate := make(chan struct{})
e.search.Gate = gate
type result struct {
got []dinners.Candidate
err error
}
done := make(chan result, 1)
go func() {
got, err := e.svc.Candidates(t.Context(), id)
done <- result{got, err}
}()
deadline := time.Now().Add(10 * time.Second)
for len(e.search.Queries()) == asked {
if time.Now().After(deadline) {
t.Fatal("candidate search never started")
}
time.Sleep(5 * time.Millisecond)
}
tt.clear(t, e, id)
close(gate)
r := <-done
if r.err != nil {
t.Fatal(r.err)
}
if len(r.got) != 1 {
t.Fatalf("candidates = %+v, want curryA", r.got)
}
if e.svc.HasOffers(id) {
t.Errorf("a search finishing after dinner %s was cleared stored its candidates", id)
}
if !tt.archived {
wantStaleCandidate(t, e, id, r.got[0].CandidateID)
}
})
}
}
// 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")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
cid := candidateID(t, e, id, "Chicken Curry")
waitNoOffers(t, e)
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) {
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}}
}
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)
}
}
// TestFixReplacesPendingLink: fixing a Regular added from a link that held
// no recipe drops that link, so it no longer blocks adding the link again,
// while the link of the recipe chosen does.
@@ -729,12 +1225,18 @@ func TestFixReplacesPendingLink(t *testing.T) {
const lost = "https://gone.example/recipes/lamb-tagine"
tests := []struct {
name string
fix dinners.Fix
// fix is the fix for dinner id.
fix func(t *testing.T, e *env, id string) dinners.Fix
// linkTaken is the recipe link the fixed dinner now has, if any.
linkTaken string
}{
{"candidate", dinners.Fix{CandidateID: ptr(curryB)}, curryB},
{"manual", dinners.Fix{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lamb"}, Minutes: ptr(90), Method: ptr("oven")}}, ""},
{"candidate", func(t *testing.T, e *env, id string) dinners.Fix {
e.search.Results["Weeknight dinner"] = []recipes.Candidate{{URL: curryB}}
return dinners.Fix{CandidateID: ptr(candidateID(t, e, id, "Easy Weeknight Curry"))}
}, curryB},
{"manual", func(*testing.T, *env, string) dinners.Fix {
return dinners.Fix{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lamb"}, Minutes: ptr(90), Method: ptr("oven")}}
}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -752,7 +1254,7 @@ func TestFixReplacesPendingLink(t *testing.T) {
t.Fatal(err)
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if _, err := e.svc.Ground(t.Context(), c.ID, tt.fix, member); err != nil {
if _, err := e.svc.Ground(t.Context(), c.ID, tt.fix(t, e, c.ID), member); err != nil {
t.Fatal(err)
}
row, err := db.New(e.st.Reader).GetDinner(t.Context(), c.ID)
@@ -855,7 +1357,8 @@ func TestFixValidation(t *testing.T) {
tests := map[string]dinners.Fix{
"nothing": {},
"two": {CandidateID: ptr(curryA), RecipeURL: ptr(stew)},
"candidate not URL": {CandidateID: ptr("cand-1")},
"unknown candidate": {CandidateID: ptr("cand-1")},
"candidate a URL": {CandidateID: ptr(curryA)},
"http link": {RecipeURL: ptr("http://a.example/x")},
"no ingredients": {Manual: &dinners.ManualRecipe{Minutes: ptr(20), Method: ptr("oven")}},
"blank ingredient": {Manual: &dinners.ManualRecipe{Ingredients: []string{" "}, Minutes: ptr(20), Method: ptr("oven")}},