Keep Pass history immutable and replenish atomic with decisions
CI / ci (push) Successful in 2m56s
CI / ci (pull_request) Successful in 2m55s

Fixes the three review findings on 50399b6 (pull request #102):

- A batch now rereads every declined name inside its storing transaction,
  so a Pass committed after the batch read its input but before it stores
  can no longer let the passed dish into the deck (SQ-01).
- A decision that leaves the deck under five creates the replenish batch
  and its job in the decision's own transaction, so a decision is never
  recorded without the replenish it calls for; when they cannot be
  written, neither is the decision, and a retry decides again (SQ-02).
  StartBatch commits the batch with its job the same way, replacing the
  mutex and the fail-on-enqueue cleanup. jobs gains Kind.EnqueueTx and
  Kind.Wake for this.
- Migration 0012 gives each suggestion the name its dish was suggested
  under; declined names for batches and the household context's passed
  list read it instead of the dinner's mutable name, so renaming a passed
  dinner no longer erases it from dedupe (SQ-03).

Task: b4f2558c-227f-4627-852b-be5aa190242a

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 23:47:45 -04:00
co-authored by Claude Opus 5.5
parent 50399b6329
commit 4e2773703b
18 changed files with 482 additions and 125 deletions
+64 -67
View File
@@ -15,7 +15,6 @@ import (
"fmt"
"log/slog"
"slices"
"sync"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
@@ -112,10 +111,6 @@ type Service struct {
suggest Suggester
logger *slog.Logger
batchJob *jobs.Kind[BatchJob]
// batchMu makes finding or creating the active batch and enqueuing its
// job one step, so a batch is never seen without its job.
batchMu sync.Mutex
}
// NewService returns a service over st that suggests with suggest, grounds
@@ -161,53 +156,54 @@ func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error)
if !slices.Contains(triggers, trigger) {
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
}
s.batchMu.Lock()
defer s.batchMu.Unlock()
var row db.SuggestionBatch
created := false
var b Batch
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetActiveSuggestionBatch(ctx)
if err == nil {
row = cur
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("suggest: read active batch: %w", err)
}
now := store.Now().String()
row, err = q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{
ID: store.NewID(),
Trigger: trigger,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("suggest: create batch: %w", err)
}
created = true
return nil
var err error
b, err = s.startBatch(ctx, tx, trigger)
return err
})
if err != nil {
return Batch{}, err
}
b := Batch{ID: row.ID, Trigger: row.Trigger, Status: row.Status, Created: created}
if !created {
b.JobID, err = db.New(s.st.Reader).GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: row.ID})
if err != nil {
return Batch{}, fmt.Errorf("suggest: read job of batch %s: %w", row.ID, err)
}
return b, nil
}
b.JobID, err = s.batchJob.Enqueue(ctx, BatchJob{BatchID: row.ID})
if err != nil {
// The batch has no job to run it, so it must not block the next.
s.finishBatch(context.WithoutCancel(ctx), row.ID, BatchFailed)
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
if b.Created {
s.batchJob.Wake()
}
return b, nil
}
// startBatch does StartBatch's work in tx: the batch and the job that runs
// it are committed together, so a batch is never seen without its job. The
// caller wakes the job runner once tx has committed and Created is true.
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
q := db.New(tx)
cur, err := q.GetActiveSuggestionBatch(ctx)
if err == nil {
jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
if err != nil {
return Batch{}, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
}
return Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return Batch{}, fmt.Errorf("suggest: read active batch: %w", err)
}
now := store.Now().String()
row, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{
ID: store.NewID(),
Trigger: trigger,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
}
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
if err != nil {
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
}
return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil
}
// finishBatch records batch id as status, logging a failure to do so.
func (s *Service) finishBatch(ctx context.Context, id, status string) {
if _, err := db.New(s.st.Writer).FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{
@@ -289,9 +285,9 @@ func (s *Service) Accept(ctx context.Context, id, updatedBy string) (Decision, e
}
// Decline records Pass on suggestion id, archiving its dinner and
// recording updatedBy on it. The archived dinner keeps its name, and the
// declined suggestion is read into every later batch, so the dish is never
// suggested again.
// recording updatedBy on it. The suggestion keeps the name its dish was
// suggested under, whatever the dinner is later renamed, and every later
// batch reads it as declined, so the dish is never suggested again.
func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision, error) {
return s.decide(ctx, id, StatusDeclined, dinners.StatusArchived, updatedBy)
}
@@ -300,15 +296,15 @@ func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision,
// dinnerStatus. Repeating a decision changes nothing and returns the
// suggestion as it is; the opposite decision is a *DecidedError. A
// decision that leaves the deck under ReplenishBelow starts a replenish
// batch unless one is already pending or running. The decision is
// committed before the batch starts, so a failure to start it is returned
// with the decision already made.
// batch unless one is already pending or running, in the decision's own
// transaction: the decision is never recorded without the replenish it
// calls for, and a failure to start the batch records neither.
func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, updatedBy string) (Decision, error) {
if updatedBy == "" {
return Decision{}, errors.New("suggest: decide: no member to record as updated_by")
}
var row db.GetSuggestionRow
changed := false
var replenish *Batch
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetSuggestion(ctx, id)
@@ -349,33 +345,34 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
if err != nil {
return fmt.Errorf("suggest: read suggestion %s: %w", id, err)
}
changed = true
left, err := q.CountDeck(ctx)
if err != nil {
return fmt.Errorf("suggest: count deck: %w", err)
}
if left >= ReplenishBelow {
return nil
}
b, err := s.startBatch(ctx, tx, TriggerReplenish)
if err != nil {
return err
}
replenish = &b
return nil
})
if err != nil {
return Decision{}, err
}
created := replenish != nil && replenish.Created
if created {
s.batchJob.Wake()
}
sug, err := fromRow(row.Suggestion, row.Dinner)
if err != nil {
return Decision{}, err
}
d := Decision{Suggestion: sug}
if !changed {
return d, nil
}
left, err := db.New(s.st.Reader).CountDeck(ctx)
if err != nil {
return Decision{}, fmt.Errorf("suggest: count deck: %w", err)
}
if left >= ReplenishBelow {
return d, nil
}
b, err := s.StartBatch(ctx, TriggerReplenish)
if err != nil {
return Decision{}, err
}
if b.Created {
d.ReplenishJobID = &b.JobID
if created {
d.ReplenishJobID = &replenish.JobID
}
return d, nil
}