Never show ideas for a failed ideas job, however it failed
CI / ci (push) Successful in 5m34s
CI / ci (pull_request) Successful in 5m26s

A failed ideas_from_ingredients job could still show ideas through
GET /api/v1/jobs/{id}: a job failed at boot as interrupted by restart
keeps its last partial result, and when the write clearing the ideas
itself failed the runner recorded internal_error over the uncleared
result. Neither path can be fixed by a write.

The jobs package gains an opt-in per-kind hook, Kind.FailedResult,
which Runner.Get applies to a failed job's stored result on every read.
The ideas kind registers withoutIdeas, which empties the ideas and keeps
the rest of the result. Kinds that do not call FailedResult are
unchanged. The write-side clear stays as a tidy-up of what is stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 19:36:04 -04:00
co-authored by Claude Opus 5.5
parent 4146f31275
commit 9ed6c305cb
5 changed files with 273 additions and 58 deletions
+45 -6
View File
@@ -135,12 +135,14 @@ type Job struct {
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
// handler is a registered kind: run runs a job, check reports whether a
// payload decodes into the kind's payload type, and serviceOnly is set when
// Runner.Enqueue refuses the kind.
// payload decodes into the kind's payload type, serviceOnly is set when
// Runner.Enqueue refuses the kind, and failedResult, when set, is what Get
// shows of a failed job's stored result (see Kind.FailedResult).
type handler struct {
run runFunc
check func(payload []byte) bool
serviceOnly bool
run runFunc
check func(payload []byte) bool
serviceOnly bool
failedResult func(stored json.RawMessage) (json.RawMessage, error)
}
// Runner owns the job handlers and the worker pool.
@@ -307,6 +309,27 @@ func (k *Kind[P]) ServiceOnly() error {
return nil
}
// FailedResult makes Get return, for a failed job of the kind with a
// stored result, show's view of that result instead of the result itself.
// It is applied on every read, so it holds however the job failed: by its
// handler, as interrupted by restart, or after a write meant to tidy the
// result failed. A show error fails Get. A kind that never calls it shows a
// failed job's last result as stored. It must be called before Start.
func (k *Kind[P]) FailedResult(show func(stored json.RawMessage) (json.RawMessage, error)) error {
if show == nil {
return fmt.Errorf("jobs: failed result %q: nil func", k.name)
}
k.r.mu.Lock()
defer k.r.mu.Unlock()
if k.r.started {
return fmt.Errorf("jobs: failed result %q: runner already started", k.name)
}
h := k.r.handlers[k.name]
h.failedResult = show
k.r.handlers[k.name] = h
return nil
}
// Wake tells an idle worker to look for a job, as Enqueue does itself:
// call it after committing the transaction a job was enqueued in.
func (k *Kind[P]) Wake() { k.r.wakeOne() }
@@ -402,7 +425,8 @@ func (r *Runner) Wait() {
r.wg.Wait()
}
// Get returns the job with id, or ErrNotFound.
// Get returns the job with id, or ErrNotFound. A failed job's result is
// shown through its kind's FailedResult, when the kind set one.
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
row, err := db.New(r.st.Reader).GetJob(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
@@ -418,6 +442,21 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
if row.Error.Valid {
job.Error = &row.Error.String
}
if job.Status == StatusFailed && job.Result != nil {
r.mu.Lock()
show := r.handlers[job.Kind].failedResult
r.mu.Unlock()
if show != nil {
shown, err := show(job.Result)
if err != nil {
return Job{}, fmt.Errorf("jobs: show failed %s result: %w", job.Kind, err)
}
if !json.Valid(shown) {
return Job{}, fmt.Errorf("jobs: show failed %s result: not one JSON value", job.Kind)
}
job.Result = shown
}
}
return job, nil
}
+44
View File
@@ -772,3 +772,47 @@ func TestServiceOnlyKind(t *testing.T) {
t.Error("ServiceOnly after Start succeeded, want an error")
}
}
func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 1)
h := func(_ context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
if err := publish(countResult{Done: 5}); err != nil {
return countResult{}, err
}
if p.Steps == 0 {
return countResult{}, &Failure{Code: "stopped"}
}
return countResult{Done: p.Steps}, nil
}
hidden := register(t, r, "hidden", h, "stopped")
plain := register(t, r, "plain", h, "stopped")
if err := hidden.FailedResult(func(json.RawMessage) (json.RawMessage, error) {
return json.RawMessage(`{"done":0}`), nil
}); err != nil {
t.Fatal(err)
}
if err := hidden.FailedResult(nil); err == nil {
t.Error("FailedResult(nil) succeeded, want an error")
}
start(t, r)
if err := hidden.FailedResult(func(s json.RawMessage) (json.RawMessage, error) { return s, nil }); err == nil {
t.Error("FailedResult after Start succeeded, want an error")
}
for _, tc := range []struct {
kind *Kind[countPayload]
steps int
want string
}{
{hidden, 0, `{"done":0}`}, // failed: shown through FailedResult
{hidden, 3, `{"done":3}`}, // succeeded: as stored
{plain, 0, `{"done":5}`}, // failed, kind without FailedResult: as stored
} {
job := waitFor(t, r, enqueue(t, tc.kind, countPayload{Steps: tc.steps}), finished)
if string(job.Result) != tc.want {
t.Errorf("%s job with %d steps: %s result = %s, want %s", job.Kind, tc.steps, job.Status, job.Result, tc.want)
}
}
}