Guard recipe repairs against same-normalized renames and member edits
CI / ci (push) Successful in 2m41s
CI / ci (pull_request) Successful in 2m31s

F1-1: GroundDinner also compares the observed raw name, so renaming
"Grandma's stew" to "Easy Grandma's stew" (same normalized_name) while a
link is grounding is ErrGroundConflict, not attached to the renamed
dinner.

F1-2: A method, minutes_total or difficulty no longer as observed before
grounding is kept over the recipe's value, as GroundPendingDinner keeps
values a member set while a dinner was pending.

F2-1: Failing to record a grounded candidate or link on the dinner (or to
read it back after a rejected write) wraps ErrGroundFailed, so the API
logs reason=ground_failed with the dinner id, never the database error.

Maestro task 8741cedb-1849-4e3f-8d05-f5648c77c7ee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 20:11:32 -04:00
co-authored by Claude Opus 5.5
parent 5c4c507ee6
commit 1dd53767f7
7 changed files with 138 additions and 35 deletions
+6 -4
View File
@@ -254,10 +254,12 @@ automatic** (D-021).
`dinner_archived`. A candidate or link is written only if the dinner is `dinner_archived`. A candidate or link is written only if the dinner is
still as it was before grounding began: one renamed or grounded some still as it was before grounding began: one renamed or grounded some
other way meanwhile is 409 `ground_conflict`, and one archived meanwhile other way meanwhile is 409 `ground_conflict`, and one archived meanwhile
409 `dinner_archived`, either leaving the dinner unchanged. Any other 409 `dinner_archived`, either leaving the dinner unchanged. A method,
failure grounding a candidate or link is 500, logged with a fixed reason minutes or difficulty a member edits meanwhile is kept over the recipe's.
and the dinner id only — never the link, which can carry a personal Any other failure grounding a candidate or link, or recording it on the
token, or dependency error text. dinner, is 500, logged with a fixed reason and the dinner id only —
never the link, which can carry a personal token, or dependency error
text.
- `/health` — liveness. - `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback. - Everything else serves the embedded PWA with SPA fallback.
+26 -13
View File
@@ -251,32 +251,40 @@ UPDATE dinners
SET recipe_id = ?1, SET recipe_id = ?1,
recipe_url = NULL, recipe_url = NULL,
photo_id = ?2, photo_id = ?2,
method = ?3, method = CASE WHEN method IS ?3
minutes_total = ?4, THEN ?4 ELSE method END,
difficulty = ?5, minutes_total = CASE WHEN minutes_total IS ?5
grounding_status = ?6, THEN ?6 ELSE minutes_total END,
difficulty = CASE WHEN difficulty IS ?7
THEN ?8 ELSE difficulty END,
grounding_status = ?9,
grounding_error = NULL, grounding_error = NULL,
updated_by = ?7, updated_by = ?10,
updated_at = ?8 updated_at = ?11
WHERE id = ?9 WHERE id = ?12
AND status <> 'archived' AND status <> 'archived'
AND normalized_name = ?10 AND name = ?13
AND grounding_status = ?11 AND normalized_name = ?14
AND grounding_revision = ?12 AND grounding_status = ?15
AND recipe_id IS ?13 AND grounding_revision = ?16
AND recipe_id IS ?17
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
` `
type GroundDinnerParams struct { type GroundDinnerParams struct {
RecipeID sql.NullString RecipeID sql.NullString
PhotoID sql.NullString PhotoID sql.NullString
ObservedMethod sql.NullString
Method sql.NullString Method sql.NullString
ObservedMinutesTotal sql.NullInt64
MinutesTotal sql.NullInt64 MinutesTotal sql.NullInt64
ObservedDifficulty sql.NullString
Difficulty sql.NullString Difficulty sql.NullString
GroundingStatus string GroundingStatus string
UpdatedBy sql.NullString UpdatedBy sql.NullString
UpdatedAt string UpdatedAt string
ID string ID string
ObservedName string
ObservedNormalizedName string ObservedNormalizedName string
ObservedGroundingStatus string ObservedGroundingStatus string
ObservedGroundingRevision int64 ObservedGroundingRevision int64
@@ -286,21 +294,26 @@ type GroundDinnerParams struct {
// Records a dinner's grounding by hand (grounded or manual) and clears any // Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the // grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. It writes only an unarchived dinner still as it was observed // link. It writes only an unarchived dinner still as it was observed
// before grounding (same normalized_name, grounding_status, // before grounding (same name, normalized_name, grounding_status,
// grounding_revision and recipe_id), so a dinner renamed, archived or // grounding_revision and recipe_id), so a dinner renamed, archived or
// grounded some other way meanwhile is left as it is. That dinner, or an // grounded some other way meanwhile is left as it is. That dinner, or an
// unknown id, returns sql.ErrNoRows. // unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
// no longer as observed is kept: a member edited it while it was grounding.
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) { func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
row := q.db.QueryRowContext(ctx, groundDinner, row := q.db.QueryRowContext(ctx, groundDinner,
arg.RecipeID, arg.RecipeID,
arg.PhotoID, arg.PhotoID,
arg.ObservedMethod,
arg.Method, arg.Method,
arg.ObservedMinutesTotal,
arg.MinutesTotal, arg.MinutesTotal,
arg.ObservedDifficulty,
arg.Difficulty, arg.Difficulty,
arg.GroundingStatus, arg.GroundingStatus,
arg.UpdatedBy, arg.UpdatedBy,
arg.UpdatedAt, arg.UpdatedAt,
arg.ID, arg.ID,
arg.ObservedName,
arg.ObservedNormalizedName, arg.ObservedNormalizedName,
arg.ObservedGroundingStatus, arg.ObservedGroundingStatus,
arg.ObservedGroundingRevision, arg.ObservedGroundingRevision,
+3 -2
View File
@@ -70,10 +70,11 @@ type Querier interface {
// Records a dinner's grounding by hand (grounded or manual) and clears any // Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the // grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. It writes only an unarchived dinner still as it was observed // link. It writes only an unarchived dinner still as it was observed
// before grounding (same normalized_name, grounding_status, // before grounding (same name, normalized_name, grounding_status,
// grounding_revision and recipe_id), so a dinner renamed, archived or // grounding_revision and recipe_id), so a dinner renamed, archived or
// grounded some other way meanwhile is left as it is. That dinner, or an // grounded some other way meanwhile is left as it is. That dinner, or an
// unknown id, returns sql.ErrNoRows. // unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
// no longer as observed is kept: a member edited it while it was grounding.
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
// Records the background grounding of an unarchived dinner still pending at // Records the background grounding of an unarchived dinner still pending at
// the grounding_revision it was grounded for; a dinner grounded some other // the grounding_revision it was grounded for; a dinner grounded some other
+25 -11
View File
@@ -335,14 +335,16 @@ type ManualRecipe struct {
// Ground grounds dinner id as f says, recording updatedBy. A candidate or // Ground grounds dinner id as f says, recording updatedBy. A candidate or
// link is grounded with recipes.Grounder.GroundURL and sets // link is grounded with recipes.Grounder.GroundURL and sets
// grounding_status grounded, with the recipe's photo, method, minutes and // grounding_status grounded, with the recipe's photo, method, minutes and
// difficulty. A manual recipe is stored as typed — no model runs — and // difficulty; one a member changed while it was grounding is kept. A
// sets grounding_status manual with its method and minutes; it has no // manual recipe is stored as typed — no model runs — and sets
// photo, and the dinner keeps its difficulty. An invalid f is a // grounding_status manual with its method and minutes; it has no photo,
// *ValidationError and an archived dinner ErrArchived. A failure grounding // and the dinner keeps its difficulty. An invalid f is a *ValidationError
// a candidate or link wraps ErrGroundFailed; a page that cannot ground the // and an archived dinner ErrArchived. A failure grounding a candidate or
// dinner is also a *recipes.ErrUngroundable. A dinner renamed, archived or // link, or recording it on the dinner, wraps ErrGroundFailed; a page that
// grounded some other way while a candidate or link was grounding is // cannot ground the dinner is also a *recipes.ErrUngroundable. A dinner
// ErrGroundConflict or ErrArchived. None of these changes the dinner. // renamed, archived or grounded some other way while a candidate or link
// was grounding is ErrGroundConflict or ErrArchived. None of these changes
// the dinner.
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) { func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
if updatedBy == "" { if updatedBy == "" {
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by") return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
@@ -380,27 +382,35 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(), UpdatedAt: store.Now().String(),
ID: cur.ID, ID: cur.ID,
ObservedName: cur.Name,
ObservedNormalizedName: cur.NormalizedName, ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus, ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision, ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID, ObservedRecipeID: cur.RecipeID,
ObservedMethod: cur.Method,
ObservedMinutesTotal: cur.MinutesTotal,
ObservedDifficulty: cur.Difficulty,
}) })
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, s.groundRejected(ctx, id) return Dinner{}, s.groundRejected(ctx, id)
} }
if err != nil { if err != nil {
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err) return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
} }
return fromRow(row), nil return fromRow(row), nil
} }
// groundRejected is the error for dinner id when it was not as observed // groundRejected is the error for dinner id when it was not as observed
// before grounding: ErrNotFound, ErrArchived or ErrGroundConflict. // before grounding: ErrNotFound, ErrArchived or ErrGroundConflict. Failing
// to read it wraps ErrGroundFailed.
func (s *Service) groundRejected(ctx context.Context, id string) error { func (s *Service) groundRejected(ctx context.Context, id string) error {
cur, err := s.get(ctx, id) cur, err := s.get(ctx, id)
if err != nil { if errors.Is(err, ErrNotFound) {
return err return err
} }
if err != nil {
return fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
if cur.Status == StatusArchived { if cur.Status == StatusArchived {
return ErrArchived return ErrArchived
} }
@@ -486,10 +496,14 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now, UpdatedAt: now,
ID: cur.ID, ID: cur.ID,
ObservedName: cur.Name,
ObservedNormalizedName: cur.NormalizedName, ObservedNormalizedName: cur.NormalizedName,
ObservedGroundingStatus: cur.GroundingStatus, ObservedGroundingStatus: cur.GroundingStatus,
ObservedGroundingRevision: cur.GroundingRevision, ObservedGroundingRevision: cur.GroundingRevision,
ObservedRecipeID: cur.RecipeID, ObservedRecipeID: cur.RecipeID,
ObservedMethod: cur.Method,
ObservedMinutesTotal: cur.MinutesTotal,
ObservedDifficulty: cur.Difficulty,
}) })
if err != nil { if err != nil {
return fmt.Errorf("dinners: ground %s: %w", id, err) return fmt.Errorf("dinners: ground %s: %w", id, err)
+41
View File
@@ -1126,6 +1126,13 @@ func TestFixInFlightWhenDinnerChanges(t *testing.T) {
}, dinners.ErrGroundConflict, func(row db.Dinner) bool { }, dinners.ErrGroundConflict, func(row db.Dinner) bool {
return row.Name == "Chicken curry" && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid return row.Name == "Chicken curry" && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
}}, }},
{"renamed to the same normalized name", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Easy Grandma's stew")}, member); err != nil {
t.Fatal(err)
}
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
return row.Name == "Easy Grandma's stew" && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
}},
{"archived", func(t *testing.T, e *env, id string) { {"archived", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Archive(t.Context(), id, member); err != nil { if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -1170,6 +1177,40 @@ func TestFixInFlightWhenDinnerChanges(t *testing.T) {
} }
} }
// TestFixInFlightKeepsMemberEdits: a method or difficulty a member sets
// while a link is grounding is kept; what they left alone takes the link's
// recipe.
func TestFixInFlightKeepsMemberEdits(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Grandma's stew")
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
e.pages.Gate = make(chan struct{})
done := make(chan error, 1)
go func() {
_, err := e.svc.Ground(context.Background(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
done <- err
}()
e.waitFetched(t, stew)
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Method: ptr("grill"), Difficulty: ptr("easy")}, member); err != nil {
t.Fatal(err)
}
close(e.pages.Gate)
if err := <-done; err != nil {
t.Fatal(err)
}
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingGrounded || !row.RecipeID.Valid {
t.Errorf("dinner = %+v, want grounded in the link's recipe", row)
}
if row.Method.String != "grill" || row.Difficulty.String != "easy" || row.MinutesTotal.Int64 != 150 {
t.Errorf("method, difficulty, minutes = %q, %q, %d; want grill and easy kept, 150 from the recipe",
row.Method.String, row.Difficulty.String, row.MinutesTotal.Int64)
}
}
// TestFixArchivedDinner: an archived dinner cannot be grounded by hand. // TestFixArchivedDinner: an archived dinner cannot be grounded by hand.
func TestFixArchivedDinner(t *testing.T) { func TestFixArchivedDinner(t *testing.T) {
t.Parallel() t.Parallel()
+27
View File
@@ -252,6 +252,33 @@ func TestPostGroundViaRecipeURLStorageFailure(t *testing.T) {
} }
} }
// TestPostGroundViaRecipeURLDinnerWriteFailure: a pasted link whose recipe
// is stored but whose dinner cannot be written is a 500 logged by reason
// and dinner id, never with the dependency error.
func TestPostGroundViaRecipeURLDinnerWriteFailure(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
if _, err := e.st.Writer.ExecContext(t.Context(),
`CREATE TRIGGER refuse_grounding BEFORE UPDATE OF recipe_id ON dinners WHEN NEW.recipe_id IS NOT NULL BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
}
var stored int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM recipes WHERE source_url = ?`, stewPage).Scan(&stored); err != nil || stored != 1 {
t.Fatalf("recipes stored for the link = %d (%v), want 1: the failure must be the dinner write", stored, err)
}
logs := e.logs.String()
if strings.Contains(logs, "disk full") || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the dependency error", logs)
}
}
// TestPostGroundArchived: grounding an archived dinner by hand is a 409. // TestPostGroundArchived: grounding an archived dinner by hand is a 409.
func TestPostGroundArchived(t *testing.T) { func TestPostGroundArchived(t *testing.T) {
t.Parallel() t.Parallel()
+10 -5
View File
@@ -85,23 +85,28 @@ RETURNING *;
-- Records a dinner's grounding by hand (grounded or manual) and clears any -- Records a dinner's grounding by hand (grounded or manual) and clears any
-- grounding_error and pasted recipe_url: the recipe chosen replaces the -- grounding_error and pasted recipe_url: the recipe chosen replaces the
-- link. It writes only an unarchived dinner still as it was observed -- link. It writes only an unarchived dinner still as it was observed
-- before grounding (same normalized_name, grounding_status, -- before grounding (same name, normalized_name, grounding_status,
-- grounding_revision and recipe_id), so a dinner renamed, archived or -- grounding_revision and recipe_id), so a dinner renamed, archived or
-- grounded some other way meanwhile is left as it is. That dinner, or an -- grounded some other way meanwhile is left as it is. That dinner, or an
-- unknown id, returns sql.ErrNoRows. -- unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
-- no longer as observed is kept: a member edited it while it was grounding.
UPDATE dinners UPDATE dinners
SET recipe_id = sqlc.arg(recipe_id), SET recipe_id = sqlc.arg(recipe_id),
recipe_url = NULL, recipe_url = NULL,
photo_id = sqlc.arg(photo_id), photo_id = sqlc.arg(photo_id),
method = sqlc.arg(method), method = CASE WHEN method IS sqlc.narg(observed_method)
minutes_total = sqlc.arg(minutes_total), THEN sqlc.arg(method) ELSE method END,
difficulty = sqlc.arg(difficulty), minutes_total = CASE WHEN minutes_total IS sqlc.narg(observed_minutes_total)
THEN sqlc.arg(minutes_total) ELSE minutes_total END,
difficulty = CASE WHEN difficulty IS sqlc.narg(observed_difficulty)
THEN sqlc.arg(difficulty) ELSE difficulty END,
grounding_status = sqlc.arg(grounding_status), grounding_status = sqlc.arg(grounding_status),
grounding_error = NULL, grounding_error = NULL,
updated_by = sqlc.arg(updated_by), updated_by = sqlc.arg(updated_by),
updated_at = sqlc.arg(updated_at) updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id) WHERE id = sqlc.arg(id)
AND status <> 'archived' AND status <> 'archived'
AND name = sqlc.arg(observed_name)
AND normalized_name = sqlc.arg(observed_normalized_name) AND normalized_name = sqlc.arg(observed_normalized_name)
AND grounding_status = sqlc.arg(observed_grounding_status) AND grounding_status = sqlc.arg(observed_grounding_status)
AND grounding_revision = sqlc.arg(observed_grounding_revision) AND grounding_revision = sqlc.arg(observed_grounding_revision)