Skip to content

Commit 651fff2

Browse files
committed
Make jobs cancelled due to a soft stop immediately available
While working on #1289, I realized that jobs which are "soft stopped" via context cancellation are still prone to the same side effects as if they errored in any other way: * Their number of attempts is incremented. * They may be discarded if reaching max attempts. * They'll have to wait to be retried according to retry policy. This doesn't really seem right because these jobs didn't actually misbehave in any way, but were rather just slow-to-run jobs that couldn't finish cleanly inside the default stop allowance while a client was restarting or being deployed. The proper behavior should probably be more like a snooze. i.e. The soft timeout cancellation doesn't count and the jobs get a chance to be retried immediately. Here, make that change.
1 parent 78ae535 commit 651fff2

8 files changed

Lines changed: 193 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ river migrate-get --database-url sqlite:// --version 6 --down > river7.down.sql
4242
- Convert SQLite JSON columns to JSONB (including migration). [PR #1224](https://github.com/riverqueue/river/pull/1224).
4343
- Change SQLite driver operations over to use bulk inserts where possible now that sqlc has better support for `json_each`. [PR #1276](https://github.com/riverqueue/river/pull/1276)
4444
- Detect duplicate step names across `river.ResumableStep` and return a validation error. [PR #1281](https://github.com/riverqueue/river/pull/1281)
45+
- Jobs that didn't finish in time organically while a client was stopping and had to have their context cancelled no longer have this cancellation counted as an error. `attempt` is reset to the number it was before the job started working, `errors` is left unchanged, and `state` is made `available` so jobs are eligible to be retried immediately. [PR #1290](https://github.com/riverqueue/river/pull/1290)
4546
- Earlier backpressure from `BatchCompleter` when it's throughput is saturated with fewer warnings to console. [PR #1292](https://github.com/riverqueue/river/pull/1292)
4647
- Series of minor optimizations in `BatchCompleter` raising throughput ~20% when it's the bottleneck in job processing (e.g. in benchmarks). [PR #1293](https://github.com/riverqueue/river/pull/1293)
4748

client_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2581,6 +2581,67 @@ func Test_Client_SoftStopTimeout(t *testing.T) {
25812581
}
25822582
})
25832583

2584+
t.Run("ErroringJobGetsFreshAttempt", func(t *testing.T) {
2585+
t.Parallel()
2586+
2587+
config := newTestConfig(t, "")
2588+
config.SoftStopTimeout = 100 * time.Millisecond
2589+
2590+
firstRunDoneChan := make(chan struct{})
2591+
jobStartedChan := make(chan int64, 2)
2592+
var runCount atomic.Int32
2593+
AddWorker(config.Workers, WorkFunc(func(ctx context.Context, job *Job[JobArgs]) error {
2594+
jobStartedChan <- job.ID
2595+
switch runCount.Add(1) {
2596+
case 1:
2597+
<-ctx.Done()
2598+
close(firstRunDoneChan)
2599+
return ctx.Err()
2600+
default:
2601+
return errors.New("real job error")
2602+
}
2603+
}))
2604+
2605+
client := runNewTestClient(ctx, t, config)
2606+
2607+
insertRes, err := client.Insert(ctx, JobArgs{}, &InsertOpts{MaxAttempts: 2})
2608+
require.NoError(t, err)
2609+
2610+
jobID := riversharedtest.WaitOrTimeout(t, jobStartedChan)
2611+
require.Equal(t, insertRes.Job.ID, jobID)
2612+
2613+
require.NoError(t, client.Stop(ctx))
2614+
riversharedtest.WaitOrTimeout(t, firstRunDoneChan)
2615+
2616+
jobAfter, err := client.driver.GetExecutor().JobGetByID(ctx, &riverdriver.JobGetByIDParams{ID: jobID, Schema: client.config.Schema})
2617+
require.NoError(t, err)
2618+
require.Equal(t, 0, jobAfter.Attempt)
2619+
require.Nil(t, jobAfter.FinalizedAt)
2620+
require.Equal(t, 2, jobAfter.MaxAttempts)
2621+
require.Equal(t, rivertype.JobStateAvailable, jobAfter.State)
2622+
require.WithinDuration(t, time.Now(), jobAfter.ScheduledAt, 2*time.Second)
2623+
require.Empty(t, jobAfter.Errors)
2624+
2625+
require.NoError(t, client.Start(ctx))
2626+
2627+
jobID = riversharedtest.WaitOrTimeout(t, jobStartedChan)
2628+
require.Equal(t, insertRes.Job.ID, jobID)
2629+
2630+
var jobAfterRealError *rivertype.JobRow
2631+
require.Eventually(t, func() bool {
2632+
var err error
2633+
jobAfterRealError, err = client.driver.GetExecutor().JobGetByID(ctx, &riverdriver.JobGetByIDParams{ID: jobID, Schema: client.config.Schema})
2634+
require.NoError(t, err)
2635+
return jobAfterRealError.State != rivertype.JobStateRunning
2636+
}, 5*time.Second, 10*time.Millisecond)
2637+
2638+
require.Equal(t, 1, jobAfterRealError.Attempt)
2639+
require.Equal(t, rivertype.JobStateRetryable, jobAfterRealError.State)
2640+
require.Len(t, jobAfterRealError.Errors, 1)
2641+
require.Equal(t, "real job error", jobAfterRealError.Errors[0].Error)
2642+
require.Less(t, time.Until(jobAfterRealError.ScheduledAt), 3*time.Second)
2643+
})
2644+
25842645
t.Run("SoftStopSucceedsBeforeTimeout", func(t *testing.T) {
25852646
t.Parallel()
25862647

internal/jobcompleter/job_completer_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -968,7 +968,7 @@ func testCompleter[TCompleter JobCompleter](
968968
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateCancelled(job1.ID, time.Now(), []byte("{}"), nil)))
969969
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateCompleted(job2.ID, time.Now(), nil)))
970970
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateDiscarded(job3.ID, time.Now(), []byte("{}"), nil)))
971-
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateErrorAvailable(job4.ID, time.Now(), []byte("{}"), nil)))
971+
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateErrorAvailable(job4.ID, time.Now(), nil, []byte("{}"), nil)))
972972
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateErrorRetryable(job5.ID, time.Now(), []byte("{}"), nil)))
973973
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateSnoozed(job6.ID, time.Now(), 10, nil)))
974974
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateSnoozedAvailable(job7.ID, time.Now(), 10, nil)))
@@ -1317,7 +1317,7 @@ func benchmarkCompleter(
13171317
require.NoError(b, err)
13181318

13191319
case 3:
1320-
err := completer.JobSetStateIfRunning(ctx, &bundle.stats[i], riverdriver.JobSetStateErrorAvailable(bundle.jobs[i].ID, time.Now(), []byte("{}"), nil))
1320+
err := completer.JobSetStateIfRunning(ctx, &bundle.stats[i], riverdriver.JobSetStateErrorAvailable(bundle.jobs[i].ID, time.Now(), nil, []byte("{}"), nil))
13211321
require.NoError(b, err)
13221322

13231323
case 4:

internal/jobexecutor/job_executor.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ import (
1919
"github.com/riverqueue/river/internal/jobcompleter"
2020
"github.com/riverqueue/river/internal/jobstats"
2121
"github.com/riverqueue/river/internal/middlewarelookup"
22+
"github.com/riverqueue/river/internal/rivercommon"
2223
"github.com/riverqueue/river/internal/workunit"
2324
"github.com/riverqueue/river/riverdriver"
2425
"github.com/riverqueue/river/rivershared/baseservice"
26+
"github.com/riverqueue/river/rivershared/util/ptrutil"
2527
"github.com/riverqueue/river/rivertype"
2628
)
2729

@@ -432,6 +434,7 @@ func (e *JobExecutor) reportError(ctx context.Context, jobRow *rivertype.JobRow,
432434
cancelJob bool
433435
cancelErr *rivertype.JobCancelError
434436
)
437+
softStopped := isSoftStopCancelError(ctx, res.Err)
435438

436439
logAttrs := []any{
437440
slog.String("error", res.ErrorStr()),
@@ -443,6 +446,8 @@ func (e *JobExecutor) reportError(ctx context.Context, jobRow *rivertype.JobRow,
443446
case errors.As(res.Err, &cancelErr):
444447
cancelJob = true
445448
e.Logger.DebugContext(ctx, e.Name+": Job cancelled explicitly", logAttrs...)
449+
case softStopped:
450+
e.Logger.InfoContext(ctx, e.Name+": Job stopped due to client shutdown; retrying", logAttrs...)
446451
case res.Err != nil:
447452
if jobRow.Attempt >= jobRow.MaxAttempts {
448453
e.Logger.InfoContext(ctx, e.Name+": Job errored", logAttrs...)
@@ -453,11 +458,21 @@ func (e *JobExecutor) reportError(ctx context.Context, jobRow *rivertype.JobRow,
453458
e.Logger.InfoContext(ctx, e.Name+": Job panicked", logAttrs...)
454459
}
455460

456-
if e.ErrorHandler != nil && !cancelJob {
461+
if e.ErrorHandler != nil && !cancelJob && !softStopped {
457462
// Error handlers also have an opportunity to cancel the job.
458463
cancelJob = e.invokeErrorHandler(ctx, res)
459464
}
460465

466+
now := e.Time.Now()
467+
468+
if softStopped {
469+
params := riverdriver.JobSetStateErrorAvailable(jobRow.ID, now, ptrutil.Ptr(max(jobRow.Attempt-1, 0)), nil, metadataUpdates)
470+
if err := e.Completer.JobSetStateIfRunning(ctx, e.stats, params); err != nil {
471+
e.Logger.ErrorContext(ctx, e.Name+": Failed to make soft-stopped job available", logAttrs...)
472+
}
473+
return
474+
}
475+
461476
attemptErr := rivertype.AttemptError{
462477
At: e.start,
463478
Attempt: jobRow.Attempt,
@@ -471,8 +486,6 @@ func (e *JobExecutor) reportError(ctx context.Context, jobRow *rivertype.JobRow,
471486
return
472487
}
473488

474-
now := e.Time.Now()
475-
476489
if cancelJob {
477490
if err := e.Completer.JobSetStateIfRunning(ctx, e.stats, riverdriver.JobSetStateCancelled(jobRow.ID, now, errData, metadataUpdates)); err != nil {
478491
e.Logger.ErrorContext(ctx, e.Name+": Failed to cancel job and report error", logAttrs...)
@@ -512,7 +525,7 @@ func (e *JobExecutor) reportError(ctx context.Context, jobRow *rivertype.JobRow,
512525
// `available` if their retry was smaller than the scheduler's run interval.
513526
var params *riverdriver.JobSetStateIfRunningParams
514527
if nextRetryScheduledAt.Sub(e.Time.Now()) <= e.SchedulerInterval {
515-
params = riverdriver.JobSetStateErrorAvailable(jobRow.ID, nextRetryScheduledAt, errData, metadataUpdates)
528+
params = riverdriver.JobSetStateErrorAvailable(jobRow.ID, nextRetryScheduledAt, nil, errData, metadataUpdates)
516529
} else {
517530
params = riverdriver.JobSetStateErrorRetryable(jobRow.ID, nextRetryScheduledAt, errData, metadataUpdates)
518531
}
@@ -521,6 +534,15 @@ func (e *JobExecutor) reportError(ctx context.Context, jobRow *rivertype.JobRow,
521534
}
522535
}
523536

537+
// isSoftStopCancelError reports whether a worker returned because the client
538+
// was stopping and cancelled its job context. The context cause distinguishes
539+
// client stop cancellation from ordinary worker cancellation or timeouts.
540+
func isSoftStopCancelError(ctx context.Context, err error) bool {
541+
return err != nil &&
542+
errors.Is(context.Cause(ctx), rivercommon.ErrStop) &&
543+
(errors.Is(err, context.Canceled) || errors.Is(err, rivercommon.ErrStop))
544+
}
545+
524546
type withJobsAndErrorsByID interface {
525547
ErrorsByID() map[int64]error
526548
Jobs() []*rivertype.JobRow

internal/jobexecutor/job_executor_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,42 @@ func TestJobExecutor_Execute(t *testing.T) {
363363
require.Equal(t, rivertype.JobStateDiscarded, job.State)
364364
})
365365

366+
// "Decrements attempt" means restoring attempt to the value it had before
367+
// JobGetAvailable incremented it for this run.
368+
t.Run("SoftStopCancelMakesJobAvailableAndDecrementsAttempt", func(t *testing.T) {
369+
t.Parallel()
370+
371+
executor, bundle := setup(t)
372+
373+
bundle.jobRow.Attempt = bundle.jobRow.MaxAttempts
374+
_, err := bundle.exec.JobUpdateFull(ctx, &riverdriver.JobUpdateFullParams{
375+
ID: bundle.jobRow.ID,
376+
AttemptDoUpdate: true,
377+
Attempt: bundle.jobRow.Attempt,
378+
})
379+
require.NoError(t, err)
380+
381+
workCtx, cancel := context.WithCancelCause(ctx)
382+
cancel(rivercommon.ErrStop)
383+
384+
executor.WorkUnit = newWorkUnitFactoryWithCustomRetry(func() error { return context.Canceled }, nil).MakeUnit(bundle.jobRow)
385+
386+
executor.Execute(workCtx)
387+
riversharedtest.WaitOrTimeout(t, bundle.updateCh)
388+
389+
job, err := bundle.exec.JobGetByID(ctx, &riverdriver.JobGetByIDParams{
390+
ID: bundle.jobRow.ID,
391+
Schema: "",
392+
})
393+
require.NoError(t, err)
394+
require.Nil(t, job.FinalizedAt)
395+
require.Equal(t, bundle.jobRow.Attempt-1, job.Attempt)
396+
require.Equal(t, bundle.jobRow.MaxAttempts, job.MaxAttempts)
397+
require.Equal(t, rivertype.JobStateAvailable, job.State)
398+
require.WithinDuration(t, time.Now(), job.ScheduledAt, 2*time.Second)
399+
require.Empty(t, job.Errors)
400+
})
401+
366402
t.Run("JobCancelErrorCancelsJobEvenWithRemainingAttempts", func(t *testing.T) {
367403
t.Parallel()
368404

riverdriver/river_driver_interface.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,8 +588,11 @@ func JobSetStateDiscarded(id int64, finalizedAt time.Time, errData []byte, metad
588588
}
589589
}
590590

591-
func JobSetStateErrorAvailable(id int64, scheduledAt time.Time, errData []byte, metadataUpdates []byte) *JobSetStateIfRunningParams {
591+
// JobSetStateErrorAvailable makes an errored job immediately available.
592+
// attempt can be set to nil to leave attempt unchanged.
593+
func JobSetStateErrorAvailable(id int64, scheduledAt time.Time, attempt *int, errData []byte, metadataUpdates []byte) *JobSetStateIfRunningParams {
592594
return &JobSetStateIfRunningParams{
595+
Attempt: attempt,
593596
ID: id,
594597
ErrData: errData,
595598
MetadataDoMerge: len(metadataUpdates) > 0,

riverdriver/river_driver_interface_test.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,36 @@ func TestJobSetStateDiscarded(t *testing.T) {
131131
func TestJobSetStateErrorAvailable(t *testing.T) {
132132
t.Parallel()
133133

134+
t.Run("Attempt", func(t *testing.T) {
135+
t.Parallel()
136+
137+
id := int64(4)
138+
scheduledAt := time.Now().Truncate(time.Second)
139+
errData := []byte("error available")
140+
attempt := 3
141+
result := JobSetStateErrorAvailable(id, scheduledAt, &attempt, errData, nil)
142+
require.Equal(t, id, result.ID)
143+
require.NotNil(t, result.Attempt)
144+
require.Equal(t, attempt, *result.Attempt)
145+
require.Equal(t, errData, result.ErrData)
146+
require.False(t, result.MetadataDoMerge)
147+
require.Nil(t, result.MetadataUpdates)
148+
require.NotNil(t, result.ScheduledAt)
149+
require.True(t, result.ScheduledAt.Equal(scheduledAt))
150+
require.Empty(t, result.Schema)
151+
require.False(t, result.Snoozed)
152+
require.Equal(t, rivertype.JobStateAvailable, result.State)
153+
})
154+
134155
t.Run("EmptyMetadata", func(t *testing.T) {
135156
t.Parallel()
136157

137158
id := int64(4)
138159
scheduledAt := time.Now().Truncate(time.Second)
139160
errData := []byte("error available")
140-
result := JobSetStateErrorAvailable(id, scheduledAt, errData, nil)
161+
result := JobSetStateErrorAvailable(id, scheduledAt, nil, errData, nil)
141162
require.Equal(t, id, result.ID)
163+
require.Nil(t, result.Attempt)
142164
require.Equal(t, errData, result.ErrData)
143165
require.False(t, result.MetadataDoMerge)
144166
require.Nil(t, result.MetadataUpdates)
@@ -156,8 +178,9 @@ func TestJobSetStateErrorAvailable(t *testing.T) {
156178
scheduledAt := time.Now().Truncate(time.Second)
157179
errData := []byte("error available")
158180
metadata := []byte(`{"key": "value"}`)
159-
result := JobSetStateErrorAvailable(id, scheduledAt, errData, metadata)
181+
result := JobSetStateErrorAvailable(id, scheduledAt, nil, errData, metadata)
160182
require.Equal(t, id, result.ID)
183+
require.Nil(t, result.Attempt)
161184
require.True(t, result.MetadataDoMerge)
162185
require.Equal(t, metadata, result.MetadataUpdates)
163186
require.NotNil(t, result.ScheduledAt)

riverdriver/riverdrivertest/job_update.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,44 @@ func exerciseJobUpdate[TTx any](ctx context.Context, t *testing.T, executorWithT
697697
require.Equal(t, "foo.go:123\nbar.go:456", jobAfter.Errors[0].Trace)
698698
})
699699

700+
t.Run("SetsARunningJobToAvailableWithUpdatedAttempt", func(t *testing.T) {
701+
t.Parallel()
702+
703+
exec, _ := setup(ctx, t)
704+
705+
now := time.Now().UTC()
706+
attempt := 2
707+
708+
job := testfactory.Job(ctx, t, exec, &testfactory.JobOpts{
709+
Attempt: ptrutil.Ptr(3),
710+
MaxAttempts: ptrutil.Ptr(3),
711+
State: ptrutil.Ptr(rivertype.JobStateRunning),
712+
UniqueKey: []byte("unique-key"),
713+
})
714+
715+
params := riverdriver.JobSetStateErrorAvailable(job.ID, now, &attempt, makeErrPayload(t, now), nil)
716+
jobsAfter, err := exec.JobSetStateIfRunningMany(ctx, setStateManyParams(params))
717+
require.NoError(t, err)
718+
jobAfter := jobsAfter[0]
719+
require.Equal(t, attempt, jobAfter.Attempt)
720+
require.Equal(t, rivertype.JobStateAvailable, jobAfter.State)
721+
require.Equal(t, 3, jobAfter.MaxAttempts)
722+
require.WithinDuration(t, now, jobAfter.ScheduledAt, time.Microsecond)
723+
724+
jobUpdated, err := exec.JobGetByID(ctx, &riverdriver.JobGetByIDParams{ID: job.ID, Schema: ""})
725+
require.NoError(t, err)
726+
require.Equal(t, attempt, jobUpdated.Attempt)
727+
require.Equal(t, rivertype.JobStateAvailable, jobUpdated.State)
728+
require.Equal(t, 3, jobUpdated.MaxAttempts)
729+
require.Equal(t, "unique-key", string(jobUpdated.UniqueKey))
730+
731+
require.Len(t, jobAfter.Errors, 1)
732+
require.Equal(t, now, jobAfter.Errors[0].At)
733+
require.Equal(t, 1, jobAfter.Errors[0].Attempt)
734+
require.Equal(t, "fake error", jobAfter.Errors[0].Error)
735+
require.Equal(t, "foo.go:123\nbar.go:456", jobAfter.Errors[0].Trace)
736+
})
737+
700738
t.Run("DoesNotTouchAlreadyRetryableJobWithNoMetadataUpdates", func(t *testing.T) {
701739
t.Parallel()
702740

0 commit comments

Comments
 (0)