Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
085456b65e | ||
|
|
0f7f8014f8 | ||
|
|
0dccf87b06 |
+6
-20
@@ -319,22 +319,15 @@ automatic** (D-021).
|
||||
suggestion with the name it is suggested under, the reason and
|
||||
`cited_regular_ids` — unless, since the job read its input, a dinner
|
||||
it repeats was added, a suggestion it repeats passed, or a Regular it
|
||||
cites renamed or archived (checked in the storing transaction). A
|
||||
notifying batch also rechecks the Side quests it holds after each round
|
||||
and lets go of those no longer eligible, so a later round grounds a
|
||||
spare in place of each; only a change committed between the last
|
||||
recheck and the store can leave it short of 5 with spares unused. The job
|
||||
cites renamed or archived (checked in the storing transaction). The job
|
||||
publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
|
||||
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
|
||||
skill or `normalize_recipe` failed; no Side quest is made up instead,
|
||||
D-017), `search_not_configured` or `search_failed`, and the batch is set
|
||||
`failed`. A `scheduled` or `manual` batch that fails before its final
|
||||
store (the AI, grounding, or the store itself) has stored nothing; a
|
||||
`failed`. A failed `scheduled` or `manual` batch has stored nothing; a
|
||||
failed `replenish` batch's Side quests stored before the failure stay
|
||||
in the deck. If
|
||||
recording the batch's end fails, its job fails, even after the final
|
||||
store committed: a notifying batch's Side quests and their
|
||||
`new_suggestions` then stay; the job runner retries
|
||||
recording the batch's end fails, its job fails; the job runner retries
|
||||
a job's final status write with backoff until it lands, each attempt
|
||||
bounded at 5 seconds; at shutdown an attempt that fails or times out is
|
||||
not retried, and the next start fails the job as interrupted. Recording
|
||||
@@ -1127,10 +1120,7 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
- The week's Supply run (Feature 17) is made from a pushed plan: after
|
||||
a successful `Push`, the handler runs `shopping.GeneratePushed` for
|
||||
the week at the plan revision `Push` pushed, and the 200 answer
|
||||
carries `supply_run: {status, error, title_job_id}`. `title_job_id` is
|
||||
the `title_supply_run` job titling a generated list, as for `POST
|
||||
/shopping/{weekStart}/generate`; null when the list already has its
|
||||
title or was not generated. `status` is `generated`;
|
||||
carries `supply_run: {status, error}`. `status` is `generated`;
|
||||
`archived` when the week's Supply run is in the Supply archive and was
|
||||
left as it was; or `failed` — error `plan_changed` (retryable, logged
|
||||
as a warning) when the plan was edited between the push and
|
||||
@@ -1153,12 +1143,8 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
when the week has no Supply run, 400 `invalid_week`.
|
||||
- `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list
|
||||
through `shopping.Generate` and answers with the same shape plus
|
||||
`skipped: [{date, dinner_id, dinner_name, reason}]` and
|
||||
`title_job_id`, the `title_supply_run` job titling the list, which
|
||||
`GET /api/v1/jobs/{id}` reports on (failed `title_failed` when the title
|
||||
could not be written); null when the list already has its title. 404
|
||||
when the week has no plan, 409 `archived` when its list is in the Supply
|
||||
archive.
|
||||
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
|
||||
has no plan, 409 `archived` when its list is in the Supply archive.
|
||||
- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes
|
||||
`{}` or `{"resend": true}` and posts the Supply run to Google Chat
|
||||
through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and
|
||||
|
||||
@@ -111,13 +111,10 @@ type pushedPlan struct {
|
||||
// plan Calendar does not show and pushing again makes it; or
|
||||
// supply_run_failed when it could not be made, and POST
|
||||
// /shopping/{weekStart}/generate makes it again. error is null when status
|
||||
// is generated. title_job_id is the job titling a generated Supply run,
|
||||
// which GET /jobs/{id} reports on; null when it already has its title or
|
||||
// was not generated.
|
||||
// is generated.
|
||||
type pushedSupplyRun struct {
|
||||
Status string `json:"status"`
|
||||
Error *apiError `json:"error"`
|
||||
TitleJobID *string `json:"title_job_id"`
|
||||
Status string `json:"status"`
|
||||
Error *apiError `json:"error"`
|
||||
}
|
||||
|
||||
// postPlanPush pushes the week's plan to the chosen Google calendar
|
||||
@@ -146,9 +143,7 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
|
||||
// gone.
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
run := pushedSupplyRun{Status: "generated"}
|
||||
if g, err := shop.GeneratePushed(ctx, week, revision); err == nil {
|
||||
run.TitleJobID = g.TitleJobID
|
||||
} else if errors.Is(err, shopping.ErrFrozen) {
|
||||
if _, err := shop.GeneratePushed(ctx, week, revision); errors.Is(err, shopping.ErrFrozen) {
|
||||
logger.InfoContext(ctx, "Supply run in the Supply archive left as it was after Push to Calendar", "week", week.String())
|
||||
run = pushedSupplyRun{Status: "archived", Error: &apiError{Code: "archived", Message: err.Error()}}
|
||||
} else if errors.Is(err, shopping.ErrPlanChanged) {
|
||||
@@ -158,7 +153,7 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
|
||||
Message: "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
||||
Retryable: true,
|
||||
}}
|
||||
} else {
|
||||
} else if err != nil {
|
||||
logger.ErrorContext(ctx, "generate Supply run after Push to Calendar", "week", week.String(), "error", err)
|
||||
run = pushedSupplyRun{Status: "failed", Error: &apiError{
|
||||
Code: "supply_run_failed",
|
||||
|
||||
@@ -60,13 +60,10 @@ type supplyRunItem struct {
|
||||
}
|
||||
|
||||
// generatedSupplyRun is the POST /shopping/{weekStart}/generate answer:
|
||||
// the Supply run as made, the plan days with nothing to shop for, and the
|
||||
// title_job_id of the job titling it, which GET /jobs/{id} reports on;
|
||||
// null when the Supply run already has its title.
|
||||
// the Supply run as made, and the plan days with nothing to shop for.
|
||||
type generatedSupplyRun struct {
|
||||
supplyRun
|
||||
Skipped []shopping.SkippedDay `json:"skipped"`
|
||||
TitleJobID *string `json:"title_job_id"`
|
||||
Skipped []shopping.SkippedDay `json:"skipped"`
|
||||
}
|
||||
|
||||
// sendChatRequest is the POST /shopping/{weekStart}/send-chat body:
|
||||
@@ -111,7 +108,7 @@ func getSupplyRun(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// postSupplyRunGenerate makes, or remakes, the week's Supply run from its
|
||||
// plan and answers with it, the plan days it skipped and its title job.
|
||||
// plan and answers with it and the plan days it skipped.
|
||||
func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
@@ -132,7 +129,7 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand
|
||||
if skipped == nil {
|
||||
skipped = []shopping.SkippedDay{}
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped, TitleJobID: g.TitleJobID})
|
||||
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("generate = %d %v", status, raw)
|
||||
}
|
||||
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "title_job_id", "total_count", "weekStart"}
|
||||
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"}
|
||||
if got := keys(raw); !reflect.DeepEqual(got, wantTop) {
|
||||
t.Errorf("generate fields = %v, want %v", got, wantTop)
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func TestSupplyRunShape(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("GET = %d %v", status, raw)
|
||||
}
|
||||
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" || s == "title_job_id" })) {
|
||||
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" })) {
|
||||
t.Errorf("GET fields = %v", k)
|
||||
}
|
||||
group := raw["groups"].([]any)[0].(map[string]any)
|
||||
@@ -376,12 +376,8 @@ func TestPushPlanMakesSupplyRun(t *testing.T) {
|
||||
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
run, _ := body["supply_run"].(map[string]any)
|
||||
if k := keys(run); run["status"] != "generated" || run["error"] != nil || !reflect.DeepEqual(k, []string{"error", "status", "title_job_id"}) {
|
||||
t.Errorf("supply_run = %v, want generated with no error", run)
|
||||
}
|
||||
if job := titleJob(t, e, run["title_job_id"]); job["status"] != "queued" {
|
||||
t.Errorf("title job = %v, want queued: no runner in this test", job)
|
||||
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
var l supplyRunJSON
|
||||
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l); status != http.StatusOK {
|
||||
@@ -471,11 +467,9 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
||||
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
run, _ := body["supply_run"].(map[string]any)
|
||||
if run["status"] != "generated" || run["error"] != nil {
|
||||
t.Errorf("supply_run = %v, want generated with no error", run)
|
||||
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
titleJobID, _ := run["title_job_id"].(string)
|
||||
held.waitEntered(t)
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
||||
@@ -484,7 +478,6 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
||||
}
|
||||
|
||||
close(held.release)
|
||||
wantTitleJobSucceeded(t, e, titleJobID)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
@@ -500,8 +493,7 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
||||
e.startRunner(t)
|
||||
|
||||
var l supplyRunJSON
|
||||
status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
|
||||
if status != http.StatusOK {
|
||||
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK {
|
||||
t.Fatalf("generate = %d %v", status, body)
|
||||
}
|
||||
if l.Title != nil {
|
||||
@@ -510,66 +502,12 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
||||
if !reflect.DeepEqual(l.render(), weekLines) {
|
||||
t.Errorf("generated = %v, want %v", l.render(), weekLines)
|
||||
}
|
||||
titleJobID, _ := body["title_job_id"].(string)
|
||||
held.waitEntered(t)
|
||||
|
||||
close(held.release)
|
||||
wantTitleJobSucceeded(t, e, titleJobID)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
if _, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil); body["title_job_id"] != nil {
|
||||
t.Errorf("remake title_job_id = %v, want null: the Supply run has its title", body["title_job_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyRunGenerateTitleJobFails: a title job that cannot title the
|
||||
// Supply run is reported failed with title_failed by GET /jobs/{id}, and
|
||||
// the week's next generation answers with a new title job.
|
||||
func TestSupplyRunGenerateTitleJobFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
e.startRunner(t)
|
||||
|
||||
_, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
first := titleJob(t, e, body["title_job_id"])
|
||||
job := waitJob(t, e.h, first["id"].(string))
|
||||
if job["status"] != "failed" || job["error"] != shopping.ReasonTitleFailed {
|
||||
t.Errorf("title job = %v, want failed %s", job, shopping.ReasonTitleFailed)
|
||||
}
|
||||
|
||||
_, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
if second := titleJob(t, e, body["title_job_id"]); second["id"] == first["id"] {
|
||||
t.Errorf("retry title_job_id = %v, want a new job", second["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// titleJob returns the title_supply_run job id, as GET /jobs/{id} reports
|
||||
// it; id must be a job id string.
|
||||
func titleJob(t *testing.T, e plansEnv, id any) map[string]any {
|
||||
t.Helper()
|
||||
s, ok := id.(string)
|
||||
if !ok || s == "" {
|
||||
t.Fatalf("title_job_id = %v, want a job id", id)
|
||||
}
|
||||
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+s, "")
|
||||
if rec.Code != http.StatusOK || job["kind"] != shopping.KindTitleSupplyRun {
|
||||
t.Fatalf("GET title job = %d %s, want a %s job", rec.Code, rec.Body, shopping.KindTitleSupplyRun)
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
// wantTitleJobSucceeded waits for title job id to finish and fails t
|
||||
// unless GET /jobs/{id} reports it succeeded with the title written.
|
||||
func wantTitleJobSucceeded(t *testing.T, e plansEnv, id string) {
|
||||
t.Helper()
|
||||
titleJob(t, e, id)
|
||||
job := waitJob(t, e.h, id)
|
||||
result, _ := job["result"].(map[string]any)
|
||||
if job["status"] != "succeeded" || result["title"] != "Chili and Friends" {
|
||||
t.Errorf("title job = %v, want succeeded with the title", job)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply
|
||||
@@ -608,7 +546,7 @@ func TestPushPlanSupplyRunFails(t *testing.T) {
|
||||
"code": "supply_run_failed",
|
||||
"message": "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run",
|
||||
"retryable": true,
|
||||
}, "title_job_id": nil}
|
||||
}}
|
||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
@@ -661,7 +599,7 @@ func TestPushPlanEditedBeforeSupplyRun(t *testing.T) {
|
||||
"code": "plan_changed",
|
||||
"message": "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
||||
"retryable": true,
|
||||
}, "title_job_id": nil}
|
||||
}}
|
||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
@@ -694,7 +632,7 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
run, _ := body["supply_run"].(map[string]any)
|
||||
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" || run["title_job_id"] != nil {
|
||||
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" {
|
||||
t.Errorf("supply_run = %v, want archived", body["supply_run"])
|
||||
}
|
||||
var l supplyRunJSON
|
||||
|
||||
@@ -311,38 +311,3 @@ func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
|
||||
t.Errorf("stored title = %v, want one of the two titles", deref(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleNotStoredOnReplacedList: a list replaced by a new
|
||||
// untitled list of the same week once archive_title has answered, before
|
||||
// its title is stored, keeps the replacement untitled; the old list's
|
||||
// title job fails, and the failure is logged.
|
||||
func TestGenerateTitleNotStoredOnReplacedList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
e.afterTitle = func() {
|
||||
for _, stmt := range []string{
|
||||
`DELETE FROM shopping_items WHERE group_id IN (SELECT id FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1))`,
|
||||
`DELETE FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1)`,
|
||||
`UPDATE shopping_lists SET id = 'replacement' WHERE week_start = ?1`,
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(stmt, week.String()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g := e.generate(t)
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
|
||||
var id string
|
||||
if err := e.st.Reader.QueryRow(`SELECT id FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "replacement" || id == g.List.ID {
|
||||
t.Fatalf("week's list = %s, want the replacement of %s", id, g.List.ID)
|
||||
}
|
||||
wantUntitled(t, "replacement's title", e.storedTitle(t))
|
||||
if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
}
|
||||
|
||||
+29
-85
@@ -54,8 +54,7 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
// no recipe grounds is dropped; any other grounding failure fails the
|
||||
// batch, as does suggest_side_quests failing, with no Side quest made up
|
||||
// instead. The batch ends done or failed. When recording the batch's end
|
||||
// fails, so does the job, with what the batch stored, and any
|
||||
// new_suggestions it emitted, kept; when it times out at shutdown, the job is left
|
||||
// fails, so does the job; when it times out at shutdown, the job is left
|
||||
// running and the batch with it, for the next start to fail.
|
||||
//
|
||||
// When a batch stores its Side quests depends on whether it notifies (see
|
||||
@@ -65,12 +64,7 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
// Side quest stored. So the household is told of exactly what the batch
|
||||
// added, once; and a batch that fails at any round, including a later
|
||||
// round's normalize_recipe, stores nothing and sends nothing, leaving no
|
||||
// Side quest in the deck that was never announced. After each round it
|
||||
// rechecks every Side quest it holds (see eligible) and lets go of those
|
||||
// the household's changes made ineligible, so a later round grounds a
|
||||
// spare Side quest in place of each; only a change committed between the
|
||||
// last recheck and the store can still leave the batch short of
|
||||
// BatchSize while spare Side quests remain. A replenish batch,
|
||||
// Side quest in the deck that was never announced. A replenish batch,
|
||||
// which tops up the deck as the household swipes and never notifies,
|
||||
// stores each round's Side quests before the next round starts, so the
|
||||
// deck fills as it goes; those stored before a failure stay in the deck.
|
||||
@@ -152,13 +146,7 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
|
||||
}
|
||||
res.Dropped += len(round) - len(grounded)
|
||||
if note != nil {
|
||||
// Recheck every held Side quest, not just this round's, so
|
||||
// one the household's changes made ineligible while this
|
||||
// round grounded is replaced by a later round, not dropped by
|
||||
// the store once no round is left to replace it.
|
||||
if held, err = s.recheck(ctx, &res, append(held, grounded...)); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
held = append(held, grounded...)
|
||||
} else if err := s.storeInto(ctx, &res, nil, grounded); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
@@ -175,22 +163,6 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// recheck returns, in order, those of a notifying batch's held Side
|
||||
// quests that store would still keep (see eligible), adding those left
|
||||
// out to res.Dropped.
|
||||
func (s *Service) recheck(ctx context.Context, res *batchResult, held []groundedQuest) ([]groundedQuest, error) {
|
||||
kept, err := s.eligible(ctx, db.New(s.st.Reader), res.BatchID, held)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Dropped += len(held) - len(kept)
|
||||
out := make([]groundedQuest, len(kept))
|
||||
for i, k := range kept {
|
||||
out[i] = k.groundedQuest
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// storeInto stores quests for res's batch with note (see store) and adds
|
||||
// the suggestions stored, and the Side quests left out, to res.
|
||||
func (s *Service) storeInto(ctx context.Context, res *batchResult, note *notice, quests []groundedQuest) error {
|
||||
@@ -305,11 +277,11 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
|
||||
// store stores each grounded Side quest of batchID as a dinner with status
|
||||
// suggested and origin side_quest, grounded in its recipe, and a pending
|
||||
// suggestion citing its Regulars by id, all in one transaction, and
|
||||
// returns the suggestion ids. A Side quest eligible leaves out is not
|
||||
// stored: since suggest_side_quests ran, a dinner it repeats was added, a
|
||||
// suggestion it repeats passed or a Rotation check dropped, or a Regular
|
||||
// it cites renamed or archived. eligible reads in the same transaction as
|
||||
// the store, so a Pass or drop committed before it is never missed.
|
||||
// returns the suggestion ids. A Side quest is left out when, since
|
||||
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
|
||||
// repeats passed or a Rotation check dropped, or a Regular it cites renamed
|
||||
// or archived. Passed names are read in the same transaction as the store,
|
||||
// so a Pass or drop committed before it is never missed.
|
||||
//
|
||||
// When note is not nil and a Side quest is stored, new_suggestions keyed by
|
||||
// note, counting and naming those stored, is emitted in the same
|
||||
@@ -323,13 +295,28 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
|
||||
var stored []string
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
kept, err := s.eligible(ctx, q, batchID, quests)
|
||||
existing, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
|
||||
}
|
||||
for _, k := range kept {
|
||||
gq := k.groundedQuest
|
||||
citedJSON, err := json.Marshal(k.cited)
|
||||
names, err := q.ListPassedNames(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: list passed names for dedupe: %w", err)
|
||||
}
|
||||
regulars := map[string][]string{}
|
||||
for _, row := range existing {
|
||||
names = append(names, row.Dinner.Name)
|
||||
if row.Dinner.Status == dinners.StatusRegular {
|
||||
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
|
||||
}
|
||||
}
|
||||
for _, gq := range quests {
|
||||
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
|
||||
if !ok || repeats(gq.quest.Name, names) {
|
||||
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
|
||||
continue
|
||||
}
|
||||
citedJSON, err := json.Marshal(cited)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: encode cited Regulars: %w", err)
|
||||
}
|
||||
@@ -367,6 +354,7 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
|
||||
}); err != nil {
|
||||
return fmt.Errorf("suggest: create suggestion: %w", err)
|
||||
}
|
||||
names = append(names, d.Name)
|
||||
ids = append(ids, id)
|
||||
stored = append(stored, d.Name)
|
||||
}
|
||||
@@ -384,50 +372,6 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// eligibleQuest is a Side quest store would keep, with the ids of the
|
||||
// Regulars it cites.
|
||||
type eligibleQuest struct {
|
||||
groundedQuest
|
||||
cited []string
|
||||
}
|
||||
|
||||
// eligible returns, in order, those of batchID's quests that q's dinners
|
||||
// and passed names still allow, each with the ids of the Regulars it
|
||||
// cites. A Side quest is left out when a dinner it repeats is unarchived,
|
||||
// a suggestion it repeats passed or a Rotation check dropped, a Regular it
|
||||
// cites is renamed or archived, or an earlier Side quest of quests kept
|
||||
// repeats it. store checks with this in its transaction; a notifying
|
||||
// batch checks the Side quests it holds with it after each round (see
|
||||
// fill).
|
||||
func (s *Service) eligible(ctx context.Context, q *db.Queries, batchID string, quests []groundedQuest) ([]eligibleQuest, error) {
|
||||
existing, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: list dinners for dedupe: %w", err)
|
||||
}
|
||||
names, err := q.ListPassedNames(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: list passed names for dedupe: %w", err)
|
||||
}
|
||||
regulars := map[string][]string{}
|
||||
for _, row := range existing {
|
||||
names = append(names, row.Dinner.Name)
|
||||
if row.Dinner.Status == dinners.StatusRegular {
|
||||
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
|
||||
}
|
||||
}
|
||||
var kept []eligibleQuest
|
||||
for _, gq := range quests {
|
||||
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
|
||||
if !ok || repeats(gq.quest.Name, names) {
|
||||
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
|
||||
continue
|
||||
}
|
||||
names = append(names, gq.quest.Name)
|
||||
kept = append(kept, eligibleQuest{groundedQuest: gq, cited: cited})
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// citedIDs returns the ids of the Regulars named cited, false when one
|
||||
// names no Regular or more than one.
|
||||
func citedIDs(cited []string, regulars map[string][]string) ([]string, bool) {
|
||||
|
||||
@@ -660,67 +660,6 @@ func TestBatchKeepsAtMostBatchSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchReplacesSideQuestMadeIneligibleWhileHeld: a notifying batch
|
||||
// holds its first round's Side quests until its last round is done; when
|
||||
// the household adds a dinner one of them repeats while that round grounds,
|
||||
// the batch lets it go and grounds a spare Side quest in its place, so it
|
||||
// still stores BatchSize and announces them once.
|
||||
func TestBatchReplacesSideQuestMadeIneligibleWhileHeld(t *testing.T) {
|
||||
for _, trigger := range []string{suggest.TriggerScheduled, suggest.TriggerManual} {
|
||||
t.Run(trigger, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
var quests, want []string
|
||||
for i := range suggest.BatchSize + 1 {
|
||||
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
|
||||
e.recipe(t, name)
|
||||
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
|
||||
if i != 0 {
|
||||
want = append(want, name)
|
||||
}
|
||||
}
|
||||
e.reply(quests...)
|
||||
// The first round's last search waits, so the household adds a
|
||||
// dinner repeating Fajita Dish A while that round grounds.
|
||||
last := fmt.Sprintf("Fajita Dish %c", 'A'+suggest.BatchSize-1)
|
||||
gate := make(chan struct{})
|
||||
e.search.Gates = map[string]chan struct{}{last: gate}
|
||||
|
||||
b, key := e.startBatch(t, trigger)
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for !slices.Contains(e.search.Queries(), last) {
|
||||
if time.Now().After(deadline) {
|
||||
close(gate)
|
||||
t.Fatalf("search for %s never ran", last)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
e.dinner(t, "Fajita Dish A", "regular")
|
||||
close(gate)
|
||||
|
||||
job := e.waitJob(t, b.JobID)
|
||||
if job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s, error %v", job.Status, job.Error)
|
||||
}
|
||||
var res batchResult
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.SuggestionIDs) != suggest.BatchSize || res.Dropped != 1 {
|
||||
t.Errorf("batch result = %+v, want %d stored and 1 dropped", res, suggest.BatchSize)
|
||||
}
|
||||
if got := e.deckNames(t); !slices.Equal(got, want) {
|
||||
t.Errorf("deck = %v, want %v", got, want)
|
||||
}
|
||||
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
||||
title := fmt.Sprintf("%d new Side quests are in", suggest.BatchSize)
|
||||
if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != title {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed %s titled %q", rows, key, title)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeckIsOldestFirst(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
|
||||
Reference in New Issue
Block a user