Suggestion batches, deck and Try it / Pass API #102

6 changed files with 153 additions and 29 deletions
Showing only changes of commit f95a31472b - Show all commits
+4 -1
View File
@@ -287,7 +287,10 @@ automatic** (D-021).
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. Each time a
not retried, and the next start fails the job as interrupted. Recording
the batch's end is bounded the same way (one attempt, 5 seconds, made
even during shutdown), so a busy writer cannot hold a stopping worker;
a batch whose end timed out is left `running` with its job. Each time a
batch is started, before the active batch is selected, every batch
still `pending` or `running` with no job queued or running (the process
stopped, or recording its end failed) is set `failed`, so it does not
+25 -18
View File
@@ -63,10 +63,20 @@ const (
retryMax = 5 * time.Second
)
// recordTimeout bounds each attempt at a job's final write, so a writer
// connection that stays busy cannot hold a stopping worker, and with it
// shutdown, open.
const recordTimeout = 5 * time.Second
// FinalWriteTimeout bounds each attempt at a job's final write, and at a
// handler's own last write, so a writer connection that stays busy cannot
// hold a stopping worker, and with it shutdown, open.
const FinalWriteTimeout = 5 * time.Second
// FinalWriteContext returns the context for one attempt at a final write
// of a job: it is not cancelled with ctx, so a job that finishes during
// shutdown is still recorded, but it ends after FinalWriteTimeout. A
// handler makes its own last write, the one recording what the job did,
// with it too, and returns the error when the attempt times out, so its
// worker can stop; the next Start fails the job as interrupted.
func FinalWriteContext(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), FinalWriteTimeout)
}
// codePattern is the form of every failure code: lower-case snake case.
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
@@ -137,8 +147,6 @@ type Runner struct {
wake chan struct{}
// claim claims the oldest queued job, marking it running at now.
claim func(ctx context.Context, now sql.NullString) (db.Job, error)
// recordTimeout bounds each attempt at a job's final write.
recordTimeout time.Duration
mu sync.Mutex
handlers map[string]handler
@@ -159,13 +167,12 @@ func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, erro
return nil, fmt.Errorf("jobs: %d workers, want at least 1", workers)
}
return &Runner{
st: st,
logger: logger,
workers: workers,
wake: make(chan struct{}, workers),
claim: db.New(st.Writer).ClaimNextJob,
recordTimeout: recordTimeout,
handlers: make(map[string]handler),
st: st,
logger: logger,
workers: workers,
wake: make(chan struct{}, workers),
claim: db.New(st.Writer).ClaimNextJob,
handlers: make(map[string]handler),
}, nil
}
@@ -493,14 +500,14 @@ func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string)
// record makes a job's final write, which must change the one running job.
// A database error is retried with backoff until the write lands, so a
// worker never moves on from a job it has finished while leaving it
// running. Each attempt is made even when ctx has been cancelled, and is
// bounded by r.recordTimeout; once ctx is cancelled, a failed or timed-out
// attempt is not retried and the job stays running, so the next Start
// fails it as interrupted.
// running. Each attempt is made with FinalWriteContext, so even when ctx
// has been cancelled, and is bounded by FinalWriteTimeout; once ctx is
// cancelled, a failed or timed-out attempt is not retried and the job
// stays running, so the next Start fails it as interrupted.
func (r *Runner) record(ctx context.Context, logger *slog.Logger, what string, write func(context.Context) (int64, error)) {
backoff := retryMin
for {
attempt, cancel := context.WithTimeout(context.WithoutCancel(ctx), r.recordTimeout)
attempt, cancel := FinalWriteContext(ctx)
n, err := write(attempt)
cancel()
if err == nil {
+3 -2
View File
@@ -645,7 +645,6 @@ func TestFinalWriteErrorIsRetried(t *testing.T) {
func TestBlockedFinalWriteDoesNotHoldShutdown(t *testing.T) {
st := migratedStore(t)
r := newRunner(t, st, 1)
r.recordTimeout = 100 * time.Millisecond
started, finish := make(chan struct{}), make(chan struct{})
k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
close(started)
@@ -676,9 +675,11 @@ func TestBlockedFinalWriteDoesNotHoldShutdown(t *testing.T) {
stop()
close(stopped)
}()
// The final write's attempt ends after 5 seconds; well past that, the
// worker is waiting for the writer with no bound.
select {
case <-stopped:
case <-time.After(5 * time.Second):
case <-time.After(15 * time.Second):
conn.Close()
<-stopped
t.Fatal("worker waited for a held writer through shutdown")
+5 -4
View File
@@ -51,7 +51,9 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// instead. Each round's grounded Side quests are stored before the next
// round starts, so the deck fills as the batch goes. The batch ends done
// or failed; Side quests stored before a failure stay in the deck. When
// recording the batch's end fails, so does the job.
// recording the batch's end 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.
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
n, err := db.New(s.st.Writer).StartSuggestionBatch(ctx, db.StartSuggestionBatchParams{
UpdatedAt: store.Now().String(),
@@ -64,14 +66,13 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
}
res, err := s.fill(ctx, p.BatchID, publish)
write := context.WithoutCancel(ctx)
if err == nil {
if err := s.finishBatch(write, p.BatchID, BatchDone); err != nil {
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
return batchResult{}, err
}
return res, nil
}
finishErr := s.finishBatch(write, p.BatchID, BatchFailed)
finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed)
if ctx.Err() != nil {
return batchResult{}, errors.Join(ctx.Err(), finishErr)
}
+9 -4
View File
@@ -217,11 +217,16 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B
return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil
}
// finishBatch records running batch id as status. Its job's handler
// returns any error, so the job never ends succeeded with the batch still
// running; the runner keeps only a code, so the error is logged too.
// finishBatch records running batch id as status, with
// jobs.FinalWriteContext: the write is made even when ctx has been
// cancelled, but a writer that stays busy cannot hold its worker past
// jobs.FinalWriteTimeout. Its job's handler returns any error, so the job
// never ends succeeded with the batch still running; the runner keeps only
// a code, so the error is logged too.
func (s *Service) finishBatch(ctx context.Context, id, status string) error {
n, err := db.New(s.st.Writer).FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{
write, cancel := jobs.FinalWriteContext(ctx)
defer cancel()
n, err := db.New(s.st.Writer).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{
Status: status,
UpdatedAt: store.Now().String(),
ID: id,
+107
View File
@@ -1029,6 +1029,113 @@ func TestBatchRecoversWhenRecordingItsEndAndItsJobsEndFail(t *testing.T) {
}
}
func TestBlockedBatchEndDoesNotHoldShutdown(t *testing.T) {
e := newEnv(t)
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
stopped := make(chan struct{})
t.Cleanup(func() {
cancel()
<-stopped
})
e.search.Gate = make(chan struct{})
e.search.Err = errors.New("search down")
e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"))
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(10 * time.Second)
for len(e.search.Queries()) == 0 {
if time.Now().After(deadline) {
t.Fatal("batch never searched")
}
time.Sleep(5 * time.Millisecond)
}
// Hold the only writer connection, so recording the batch's end
// waits for it once the search fails.
conn, err := e.st.Writer.Conn(t.Context())
if err != nil {
t.Fatal(err)
}
waits := e.st.Writer.Stats().WaitCount
close(e.search.Gate)
for e.st.Writer.Stats().WaitCount == waits {
if time.Now().After(deadline) {
conn.Close()
t.Fatal("batch end never waited for the writer")
}
time.Sleep(5 * time.Millisecond)
}
go func() {
e.runner.Wait()
close(stopped)
}()
cancel()
// Each final write's attempt ends after jobs.FinalWriteTimeout; well
// past that, the worker is waiting for the writer with no bound.
select {
case <-stopped:
case <-time.After(jobs.FinalWriteTimeout + 10*time.Second):
conn.Close()
<-stopped
t.Fatal("worker waited for a held writer through shutdown")
}
if err := conn.Close(); err != nil {
t.Fatal(err)
}
if got := e.batchStatus(t, b.ID); got != suggest.BatchRunning {
t.Fatalf("after shutdown batch status = %s, want left running", got)
}
// The next process fails the job as interrupted, and the batch no
// longer blocks the next one.
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
next, err := jobs.NewRunner(e.st, logger, 1)
if err != nil {
t.Fatal(err)
}
refuse := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("not wanted")
}
svc, err := suggest.NewService(e.st, e.grounder, refuse, next, logger)
if err != nil {
t.Fatal(err)
}
nextCtx, nextCancel := context.WithCancel(context.Background())
if err := next.Start(nextCtx); err != nil {
nextCancel()
t.Fatal(err)
}
t.Cleanup(func() {
nextCancel()
next.Wait()
})
job, err := next.Get(t.Context(), b.JobID)
if err != nil {
t.Fatal(err)
}
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != jobs.CodeInterrupted {
t.Fatalf("interrupted job = %+v, want failed with %q", job, jobs.CodeInterrupted)
}
again, err := svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
if !again.Created || again.ID == b.ID {
t.Errorf("next batch = %+v, want a new one", again)
}
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
t.Errorf("interrupted batch status = %s, want failed", got)
}
}
func TestStartBatchRefusesUnknownTrigger(t *testing.T) {
e := newEnv(t)
if _, err := e.svc.StartBatch(t.Context(), "whenever"); err == nil {