Bound each attempt at a job's final write
CI / ci (push) Successful in 2m55s
CI / ci (pull_request) Successful in 2m49s

A final write used a context that could never be cancelled, so a worker
waiting for a held writer connection kept runner.Wait, and with it
shutdown, open indefinitely. Each attempt now has a 5-second deadline;
once shutdown begins, a failed or timed-out attempt is not retried and
the next start fails the job as interrupted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 00:29:28 -04:00
co-authored by Claude Opus 5.5
parent d5cd47a775
commit 76ec60d219
3 changed files with 82 additions and 11 deletions
+3 -1
View File
@@ -285,7 +285,9 @@ automatic** (D-021).
D-017), `search_not_configured` or `search_failed`, and the batch is set
`failed`; Side quests stored before the failure stay in the deck. If
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 time a
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
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
+21 -10
View File
@@ -63,6 +63,11 @@ 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
// codePattern is the form of every failure code: lower-case snake case.
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
@@ -132,6 +137,8 @@ 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
@@ -152,12 +159,13 @@ 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,
handlers: make(map[string]handler),
st: st,
logger: logger,
workers: workers,
wake: make(chan struct{}, workers),
claim: db.New(st.Writer).ClaimNextJob,
recordTimeout: recordTimeout,
handlers: make(map[string]handler),
}, nil
}
@@ -485,13 +493,16 @@ 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; once it
// is, a failed attempt is not retried and the job stays running, so the
// next Start fails it as interrupted.
// 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.
func (r *Runner) record(ctx context.Context, logger *slog.Logger, what string, write func(context.Context) (int64, error)) {
backoff := retryMin
for {
n, err := write(context.WithoutCancel(ctx))
attempt, cancel := context.WithTimeout(context.WithoutCancel(ctx), r.recordTimeout)
n, err := write(attempt)
cancel()
if err == nil {
if n != 1 {
logger.ErrorContext(ctx, what, "error", "job is not running")
+58
View File
@@ -642,6 +642,64 @@ 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)
<-finish
return countResult{Done: p.Steps}, nil
})
stop := start(t, r)
id := enqueue(t, k, countPayload{Steps: 3})
<-started
// Hold the only writer connection, so the job's final write waits for it.
conn, err := st.Writer.Conn(t.Context())
if err != nil {
t.Fatal(err)
}
waits := st.Writer.Stats().WaitCount
close(finish)
deadline := time.Now().Add(5 * time.Second)
for st.Writer.Stats().WaitCount == waits {
if time.Now().After(deadline) {
t.Fatal("final write never waited for the writer")
}
time.Sleep(5 * time.Millisecond)
}
stopped := make(chan struct{})
go func() {
stop()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(5 * 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 job := waitFor(t, r, id, func(Job) bool { return true }); job.Status != StatusRunning {
t.Fatalf("after shutdown job = %+v, want left running", job)
}
second := newRunner(t, st, 1)
register(t, second, "count", func(context.Context, countPayload, func(countResult) error) (countResult, error) {
return countResult{}, nil
})
start(t, second)
if job := waitFor(t, second, id, finished); job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInterrupted {
t.Errorf("interrupted job = %+v, want failed with %q", job, CodeInterrupted)
}
}
func TestRunnerEnqueueByKindName(t *testing.T) {
t.Parallel()
st := migratedStore(t)