Skip to content

Commit 8f3b72d

Browse files
committed
Basic stuck job detection
Here, try to make some inroads on a feature we've been talking about for a while: detection of stuck jobs. Unfortunately in Go it's quite easy to accidentally park a job by using a `select` on a channel that won't return and forgetting a separate branch for `<-ctx.Done()` so that it won't respect job timeouts either. Here, add in some basic detection for that case. Eventually we'd like to give users some options for what to do in case jobs become stuck, but here we do only the simplest things for now: log when we detect a stuck job and count the number of stuck jobs in a producer's stats loop. In the future we may want to have some additional intelligence like having producers move stuck jobs to a separate bucket up to a certain limit before crashing (the next best option because it's not possible to manually kill goroutines).
1 parent dce66cd commit 8f3b72d

3 files changed

Lines changed: 128 additions & 41 deletions

File tree

internal/jobexecutor/job_executor.go

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -104,20 +104,23 @@ func (r *jobExecutorResult) ErrorStr() string {
104104
type JobExecutor struct {
105105
baseservice.BaseService
106106

107-
CancelFunc context.CancelCauseFunc
108-
ClientJobTimeout time.Duration
109-
Completer jobcompleter.JobCompleter
110-
ClientRetryPolicy ClientRetryPolicy
111-
DefaultClientRetryPolicy ClientRetryPolicy
112-
ErrorHandler ErrorHandler
113-
HookLookupByJob *hooklookup.JobHookLookup
114-
HookLookupGlobal hooklookup.HookLookupInterface
115-
InformProducerDoneFunc func(jobRow *rivertype.JobRow)
116-
JobRow *rivertype.JobRow
117-
MiddlewareLookupGlobal middlewarelookup.MiddlewareLookupInterface
118-
SchedulerInterval time.Duration
119-
WorkerMiddleware []rivertype.WorkerMiddleware
120-
WorkUnit workunit.WorkUnit
107+
CancelFunc context.CancelCauseFunc
108+
ClientJobTimeout time.Duration
109+
Completer jobcompleter.JobCompleter
110+
ClientRetryPolicy ClientRetryPolicy
111+
DefaultClientRetryPolicy ClientRetryPolicy
112+
ErrorHandler ErrorHandler
113+
HookLookupByJob *hooklookup.JobHookLookup
114+
HookLookupGlobal hooklookup.HookLookupInterface
115+
InformProducerDoneFunc func(jobRow *rivertype.JobRow)
116+
InformProducerStuckFunc func()
117+
InformProducerUnstuckFunc func()
118+
JobRow *rivertype.JobRow
119+
MiddlewareLookupGlobal middlewarelookup.MiddlewareLookupInterface
120+
SchedulerInterval time.Duration
121+
StuckThresholdOverride time.Duration
122+
WorkerMiddleware []rivertype.WorkerMiddleware
123+
WorkUnit workunit.WorkUnit
121124

122125
// Meant to be used from within the job executor only.
123126
start time.Time
@@ -171,6 +174,49 @@ func (e *JobExecutor) execute(ctx context.Context) (res *jobExecutorResult) {
171174
metadataUpdates := make(map[string]any)
172175
ctx = context.WithValue(ctx, ContextKeyMetadataUpdates, metadataUpdates)
173176

177+
// Watches for jobs that may have become stuck. i.e. They've run longer than
178+
// their job timeout (plus a small margin) and don't appear to be responding
179+
// to context cancellation (unfortunately, quite an easy error to make in
180+
// Go).
181+
//
182+
// Currently we don't do anything if we notice a job is stuck. Knowing about
183+
// stuck jobs is just used for informational purposes in the producer in
184+
// generating periodic stats.
185+
if e.ClientJobTimeout > 0 {
186+
ctx, cancel := context.WithCancel(ctx)
187+
defer cancel()
188+
189+
go func() {
190+
const stuckThresholdDefault = 5 * time.Second
191+
192+
select {
193+
case <-ctx.Done():
194+
// cancellation or execution finished
195+
196+
case <-time.After(e.ClientJobTimeout + cmp.Or(e.StuckThresholdOverride, stuckThresholdDefault)):
197+
e.InformProducerStuckFunc()
198+
199+
e.Logger.WarnContext(ctx, e.Name+": Job appears to be stuck",
200+
slog.Int64("job_id", e.JobRow.ID),
201+
slog.String("kind", e.JobRow.Kind),
202+
slog.Duration("timeout", e.ClientJobTimeout),
203+
)
204+
205+
// In case the executor ever becomes unstuck, inform the
206+
// producer. However, if we got all the way here there's a good
207+
// chance this will never happen (the worker is really stuck and
208+
// will never return).
209+
defer e.InformProducerUnstuckFunc()
210+
211+
defer e.Logger.InfoContext(ctx, e.Name+": Job became unstuck",
212+
slog.Duration("duration", time.Since(e.start)),
213+
slog.Int64("job_id", e.JobRow.ID),
214+
slog.String("kind", e.JobRow.Kind),
215+
)
216+
}
217+
}()
218+
}
219+
174220
defer func() {
175221
if recovery := recover(); recovery != nil {
176222
e.Logger.ErrorContext(ctx, e.Name+": panic recovery; possible bug with Worker",

internal/jobexecutor/job_executor_test.go

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -184,18 +184,20 @@ func TestJobExecutor_Execute(t *testing.T) {
184184
t.Cleanup(func() { cancel(nil) })
185185

186186
executor := baseservice.Init(archetype, &JobExecutor{
187-
CancelFunc: cancel,
188-
ClientRetryPolicy: &retrypolicytest.RetryPolicyNoJitter{},
189-
Completer: bundle.completer,
190-
DefaultClientRetryPolicy: &retrypolicytest.RetryPolicyNoJitter{},
191-
ErrorHandler: bundle.errorHandler,
192-
HookLookupByJob: hooklookup.NewJobHookLookup(),
193-
HookLookupGlobal: hooklookup.NewHookLookup(nil),
194-
InformProducerDoneFunc: func(job *rivertype.JobRow) {},
195-
JobRow: bundle.jobRow,
196-
MiddlewareLookupGlobal: middlewarelookup.NewMiddlewareLookup(nil),
197-
SchedulerInterval: riverinternaltest.SchedulerShortInterval,
198-
WorkUnit: workUnitFactory.MakeUnit(bundle.jobRow),
187+
CancelFunc: cancel,
188+
ClientRetryPolicy: &retrypolicytest.RetryPolicyNoJitter{},
189+
Completer: bundle.completer,
190+
DefaultClientRetryPolicy: &retrypolicytest.RetryPolicyNoJitter{},
191+
ErrorHandler: bundle.errorHandler,
192+
HookLookupByJob: hooklookup.NewJobHookLookup(),
193+
HookLookupGlobal: hooklookup.NewHookLookup(nil),
194+
InformProducerDoneFunc: func(job *rivertype.JobRow) {},
195+
InformProducerStuckFunc: func() {},
196+
InformProducerUnstuckFunc: func() {},
197+
JobRow: bundle.jobRow,
198+
MiddlewareLookupGlobal: middlewarelookup.NewMiddlewareLookup(nil),
199+
SchedulerInterval: riverinternaltest.SchedulerShortInterval,
200+
WorkUnit: workUnitFactory.MakeUnit(bundle.jobRow),
199201
})
200202

201203
return executor, bundle
@@ -696,6 +698,36 @@ func TestJobExecutor_Execute(t *testing.T) {
696698
})
697699
})
698700

701+
t.Run("StuckDetection", func(t *testing.T) {
702+
t.Parallel()
703+
704+
executor, bundle := setup(t)
705+
706+
executor.ClientJobTimeout = 5 * time.Millisecond
707+
executor.StuckThresholdOverride = 1 * time.Nanosecond // must be greater than 0 to take effect
708+
709+
var (
710+
informProducerStuckReceived = make(chan struct{})
711+
informProducerUnstuckReceived = make(chan struct{})
712+
)
713+
executor.InformProducerStuckFunc = func() {
714+
close(informProducerStuckReceived)
715+
}
716+
executor.InformProducerUnstuckFunc = func() {
717+
close(informProducerUnstuckReceived)
718+
}
719+
720+
executor.WorkUnit = newWorkUnitFactoryWithCustomRetry(func() error {
721+
riversharedtest.WaitOrTimeout(t, informProducerStuckReceived)
722+
return nil
723+
}, nil).MakeUnit(bundle.jobRow)
724+
725+
executor.Execute(ctx)
726+
_ = riversharedtest.WaitOrTimeout(t, bundle.updateCh)
727+
728+
riversharedtest.WaitOrTimeout(t, informProducerUnstuckReceived)
729+
})
730+
699731
t.Run("Panic", func(t *testing.T) {
700732
t.Parallel()
701733

producer.go

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ type producer struct {
209209
// An atomic count of the number of jobs actively being worked on. This is
210210
// written to by the main goroutine, but read by the dispatcher.
211211
numJobsActive atomic.Int32
212+
numJobsStuck atomic.Int32
212213

213214
numJobsRan atomic.Uint64
214215
paused bool
@@ -771,20 +772,26 @@ func (p *producer) heartbeatLogLoop(ctx context.Context, wg *sync.WaitGroup) {
771772
ticker := time.NewTicker(5 * time.Second)
772773
defer ticker.Stop()
773774
type jobCount struct {
774-
ran uint64
775775
active int
776+
ran uint64
777+
stuck int
776778
}
777779
var prevCount jobCount
778780
for {
779781
select {
780782
case <-ctx.Done():
781783
return
782784
case <-ticker.C:
783-
curCount := jobCount{ran: p.numJobsRan.Load(), active: int(p.numJobsActive.Load())}
785+
curCount := jobCount{
786+
active: int(p.numJobsActive.Load()),
787+
ran: p.numJobsRan.Load(),
788+
stuck: int(p.numJobsStuck.Load()),
789+
}
784790
if curCount != prevCount {
785791
p.Logger.InfoContext(ctx, p.Name+": Producer job counts",
786792
slog.Uint64("num_completed_jobs", curCount.ran),
787793
slog.Int("num_jobs_running", curCount.active),
794+
slog.Int("num_jobs_stuck", curCount.stuck),
788795
slog.String("queue", p.config.Queue),
789796
)
790797
}
@@ -806,19 +813,21 @@ func (p *producer) startNewExecutors(workCtx context.Context, jobs []*rivertype.
806813
jobCtx, jobCancel := context.WithCancelCause(workCtx)
807814

808815
executor := baseservice.Init(&p.Archetype, &jobexecutor.JobExecutor{
809-
CancelFunc: jobCancel,
810-
ClientJobTimeout: p.jobTimeout,
811-
ClientRetryPolicy: p.retryPolicy,
812-
Completer: p.completer,
813-
DefaultClientRetryPolicy: &DefaultClientRetryPolicy{},
814-
ErrorHandler: p.errorHandler,
815-
HookLookupByJob: p.config.HookLookupByJob,
816-
HookLookupGlobal: p.config.HookLookupGlobal,
817-
MiddlewareLookupGlobal: p.config.MiddlewareLookupGlobal,
818-
InformProducerDoneFunc: p.handleWorkerDone,
819-
JobRow: job,
820-
SchedulerInterval: p.config.SchedulerInterval,
821-
WorkUnit: workUnit,
816+
CancelFunc: jobCancel,
817+
ClientJobTimeout: p.jobTimeout,
818+
ClientRetryPolicy: p.retryPolicy,
819+
Completer: p.completer,
820+
DefaultClientRetryPolicy: &DefaultClientRetryPolicy{},
821+
ErrorHandler: p.errorHandler,
822+
HookLookupByJob: p.config.HookLookupByJob,
823+
HookLookupGlobal: p.config.HookLookupGlobal,
824+
MiddlewareLookupGlobal: p.config.MiddlewareLookupGlobal,
825+
InformProducerDoneFunc: p.handleWorkerDone,
826+
InformProducerStuckFunc: func() { p.numJobsStuck.Add(1) },
827+
InformProducerUnstuckFunc: func() { p.numJobsStuck.Add(-1) },
828+
JobRow: job,
829+
SchedulerInterval: p.config.SchedulerInterval,
830+
WorkUnit: workUnit,
822831
})
823832
p.addActiveJob(job.ID, executor)
824833

0 commit comments

Comments
 (0)