Keep one draft in flight per week
CI / ci (push) Successful in 3m26s
CI / ci (pull_request) Successful in 3m10s

A confirmed draft asked for while the week's draft job was asked for
without confirm no longer enqueues a second job. With two job workers the
two drafts ran at once, and whichever stored second failed plan_changed,
so the household's confirmed draft could fail. StartDraft now refuses it
with ErrDraftRunning, 409 draft_running, and enqueues nothing; the
household asks again once the running draft finishes. Every other draft
joins the week's queued or running job, so a week has at most one.

The plan tests run the job runner with two workers, as cmd/server does,
and a confirmed draft during a running unconfirmed one is refused while
only the running job drafts.

Maestro task ea48e650-d510-49cc-8485-0f07174ee731.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 06:46:13 -04:00
co-authored by Claude Opus 5.5
parent 7e6e49f6b1
commit 45fcd71d27
8 changed files with 133 additions and 117 deletions
+8 -4
View File
@@ -44,8 +44,9 @@ func getPlan(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
// postPlanDraft enqueues the draft of the week and answers 202 with the
// jobId that GET /jobs/{id} reports on, without waiting for the job. When
// a draft of the week is already queued or running, confirmed if this one
// is, it answers 202 with that job instead.
// a draft of the week is already queued or running, it answers 202 with
// that job instead, or 409 when this draft is confirmed and that one is
// not.
func postPlanDraft(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
@@ -105,8 +106,9 @@ func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger
// writePlanError maps a plan.Service error to its API error: a week with
// no plan or an unknown dinner is 404, a date outside the week is 400, and
// drafting over a synced plan without confirm, or planning a dinner that
// is neither a Regular nor in the Quest log, is 409.
// drafting over a synced plan without confirm, a confirmed draft while an
// unconfirmed one runs, or planning a dinner that is neither a Regular nor
// in the Quest log, is 409.
func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var day *plan.InvalidDayError
var week *plan.InvalidWeekError
@@ -120,6 +122,8 @@ func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger,
writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error())
case errors.Is(err, plan.ErrConfirmRequired):
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
case errors.Is(err, plan.ErrDraftRunning):
writeError(w, r, logger, http.StatusConflict, "draft_running", err.Error())
case errors.Is(err, plan.ErrNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "plan not found")
case errors.Is(err, plan.ErrDinnerNotFound):
+10 -8
View File
@@ -184,16 +184,18 @@ func TestPostPlanDraft(t *testing.T) {
t.Errorf("POST draft of a new week = %d %v, want 202 with its own job", rec.Code, body)
}
// A confirmed draft does not join the unconfirmed one, but a second
// confirmed draft joins the first.
// A confirmed draft does not join the unconfirmed one and enqueues
// nothing.
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`)
confirmedID, _ := body["jobId"].(string)
if rec.Code != http.StatusAccepted || confirmedID == "" || confirmedID == jobID {
t.Errorf("confirmed POST draft = %d %v, want 202 with its own job", rec.Code, body)
if rec.Code != http.StatusConflict || errorCode(body) != "draft_running" {
t.Errorf("confirmed POST draft = %d %v, want 409 draft_running", rec.Code, body)
}
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`)
if rec.Code != http.StatusAccepted || body["jobId"] != confirmedID {
t.Errorf("second confirmed POST draft = %d %v, want 202 with job %s", rec.Code, body, confirmedID)
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 2 {
t.Errorf("%d draft jobs, want one per week", queued)
}
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":"yes"}`)