Skip to content

Commit 03380cf

Browse files
committed
Add Config.HardStopTimeout to perform a "hard stop" setting jobs errored
Here, add a new `Config.HardStopTimeout` on top of the existing `SoftStopTimeout` whose job it is to recover badly behaving job as much as possible before coming to a full stop. Currently, if a client is stopping and is running jobs that don't respond to context cancellation, those jobs end up getting left in a `running` state, which means that they won't be recoverable again until they're rescued an hour later. `HardStopTimeout` engages after soft stop, and has each producer perform a "hard stop", which means to have it set any jobs still running to an error state. Because they're errored, they'll get to run immediately the next time a client starts up. Ideally, users don't need to depend on this functionality since the "correct" behavior would be to make sure that all jobs are able to respond to context cancellation, so we make this new feature optional.
1 parent 238776f commit 03380cf

7 files changed

Lines changed: 463 additions & 60 deletions

File tree

client.go

Lines changed: 100 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,20 @@ type Config struct {
223223
// Defaults to 1 minute.
224224
JobTimeout time.Duration
225225

226+
// HardStopTimeout is the maximum amount of time that the client will wait
227+
// after job contexts are cancelled during a shutdown "soft stop" before
228+
// forcing jobs still running (i.e. those which did not respond to context
229+
// cancellation) to an errored state. This hard stop phase lets jobs be
230+
// retried immediately on the next client start instead of waiting for
231+
// rescue.
232+
//
233+
// The timer starts only after a soft stop has begun by cancelling job
234+
// contexts, like after SoftStopTimeout elapses, StopAndCancel is called, or
235+
// the Start context is cancelled without SoftStopTimeout configured.
236+
//
237+
// Defaults to no timeout (hard stop disabled).
238+
HardStopTimeout time.Duration
239+
226240
// Hooks are functions that may activate at certain points during a job's
227241
// lifecycle (see rivertype.Hook), installed globally.
228242
//
@@ -378,11 +392,9 @@ type Config struct {
378392
Schema string
379393

380394
// SoftStopTimeout is the maximum amount of time that the client will wait
381-
// for running jobs to finish during a stop before their contexts are
382-
// cancelled. After the timeout elapses, the client escalates to a hard stop
383-
// by cancelling the context of all running jobs. This applies regardless of
384-
// how stop is initiated — whether by calling Stop, StopAndCancel, or by
385-
// cancelling the context passed to Start.
395+
// for running jobs to finish during a graceful stop before entering soft
396+
// stop by cancelling job contexts. This applies when stop is initiated by
397+
// calling Stop or by cancelling the context passed to Start.
386398
//
387399
// In combination with signal.NotifyContext on the context passed to Start,
388400
// this can simplify graceful stop to:
@@ -393,12 +405,12 @@ type Config struct {
393405
// if err := client.Start(ctx); err != nil { ... }
394406
// <-client.Stopped()
395407
//
396-
// The signal cancels the Start context, which initiates a soft stop. If
408+
// The signal cancels the Start context, which initiates a graceful stop. If
397409
// running jobs haven't finished after SoftStopTimeout, their contexts are
398-
// automatically cancelled to trigger a hard stop.
410+
// cancelled.
399411
//
400-
// StopAndCancel bypasses the timeout entirely and cancels job contexts
401-
// immediately.
412+
// StopAndCancel cancels job contexts immediately instead of waiting for
413+
// SoftStopTimeout.
402414
//
403415
// Defaults to no timeout (wait indefinitely for jobs to finish).
404416
SoftStopTimeout time.Duration
@@ -516,6 +528,7 @@ func (c *Config) WithDefaults() *Config {
516528
ErrorHandler: c.ErrorHandler,
517529
FetchCooldown: cmp.Or(c.FetchCooldown, FetchCooldownDefault),
518530
FetchPollInterval: cmp.Or(c.FetchPollInterval, FetchPollIntervalDefault),
531+
HardStopTimeout: c.HardStopTimeout,
519532
ID: valutil.ValOrDefaultFunc(c.ID, func() string { return defaultClientID(time.Now().UTC()) }),
520533
Hooks: c.Hooks,
521534
JobInsertMiddleware: c.JobInsertMiddleware,
@@ -566,6 +579,9 @@ func (c *Config) validate() error {
566579
if c.FetchPollInterval < c.FetchCooldown {
567580
return fmt.Errorf("FetchPollInterval cannot be shorter than FetchCooldown (%s)", c.FetchCooldown)
568581
}
582+
if c.HardStopTimeout < 0 {
583+
return errors.New("HardStopTimeout cannot be less than zero")
584+
}
569585
if len(c.ID) > 100 {
570586
return errors.New("ID cannot be longer than 100 characters")
571587
}
@@ -601,6 +617,9 @@ func (c *Config) validate() error {
601617
if c.Schema != "" && !postgresSchemaNameRE.MatchString(c.Schema) {
602618
return errors.New("Schema name can only contain letters, numbers, and underscores, and must start with a letter or underscore")
603619
}
620+
if c.SoftStopTimeout < 0 {
621+
return errors.New("SoftStopTimeout cannot be less than zero")
622+
}
604623

605624
for queue, queueConfig := range c.Queues {
606625
if err := queueConfig.validate(queue, c.FetchCooldown, c.FetchPollInterval); err != nil {
@@ -1069,10 +1088,12 @@ func NewClient[TTx any](driver riverdriver.Driver[TTx], config *Config) (*Client
10691088
// A graceful shutdown stops fetching new jobs but allows any previously fetched
10701089
// jobs to complete. This can be initiated with the Stop method.
10711090
//
1072-
// A more abrupt shutdown can be achieved by either cancelling the provided
1073-
// context or by calling StopAndCancel. This will not only stop fetching new
1074-
// jobs, but will also cancel the context for any currently-running jobs. If
1075-
// using StopAndCancel, there's no need to also call Stop.
1091+
// A soft stop cancels job contexts after fetching has stopped. It can be
1092+
// initiated by calling StopAndCancel, by cancelling the provided context when
1093+
// SoftStopTimeout is not configured, or by waiting for SoftStopTimeout to elapse
1094+
// during graceful stop. If HardStopTimeout is configured, jobs still running
1095+
// after that timeout will be forced into an errored state. If using
1096+
// StopAndCancel, there's no need to also call Stop.
10761097
func (c *Client[TTx]) Start(ctx context.Context) error {
10771098
fetchCtx, shouldStart, started, stopped := c.baseStartStop.StartInit(ctx)
10781099
if !shouldStart {
@@ -1086,9 +1107,13 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
10861107
// sure to take a channel reference before finishing stopped.
10871108
c.stopped = c.baseStartStop.StoppedUnsafe()
10881109

1089-
producersAsServices := func() []startstop.Service {
1110+
producers := func() []*producer {
1111+
return maputil.Values(c.producersByQueueName)
1112+
}
1113+
1114+
producersAsServices := func(producers []*producer) []startstop.Service {
10901115
return sliceutil.Map(
1091-
maputil.Values(c.producersByQueueName),
1116+
producers,
10921117
func(p *producer) startstop.Service { return p },
10931118
)
10941119
}
@@ -1142,8 +1167,8 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
11421167
// We use separate contexts for fetching and working to allow for a
11431168
// graceful stop. When SoftStopTimeout is configured, the work context
11441169
// is detached from the start context so that cancelling the start
1145-
// context initiates a soft stop (with timeout escalation) rather than
1146-
// an immediate hard stop. When SoftStopTimeout is not configured, the
1170+
// context initiates a graceful stop (with timeout escalation) rather
1171+
// than an immediate soft stop. When SoftStopTimeout is not configured, the
11471172
// work context inherits from the start context to preserve the
11481173
// existing behavior where cancelling the start context is equivalent
11491174
// to StopAndCancel.
@@ -1166,7 +1191,7 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
11661191
for _, producer := range c.producersByQueueName {
11671192
if err := producer.StartWorkContext(fetchCtx, workCtx); err != nil {
11681193
workCancel(err)
1169-
startstop.StopAllParallel(producersAsServices()...)
1194+
startstop.StopAllParallel(producersAsServices(producers())...)
11701195
stopServicesOnError()
11711196
return err
11721197
}
@@ -1188,7 +1213,7 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
11881213
// Generate producer services while c.queues.startStopMu.Lock() is still
11891214
// held. This is used for WaitAllStarted below, but don't use it elsewhere
11901215
// because new producers may have been added while the client is running.
1191-
producerServices := producersAsServices()
1216+
producerServices := producersAsServices(producers())
11921217

11931218
go func() {
11941219
// Wait for all subservices to start up before signaling our own start.
@@ -1215,22 +1240,57 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
12151240
c.queues.startStopMu.Lock()
12161241
defer c.queues.startStopMu.Unlock()
12171242

1243+
producerList := producers()
1244+
1245+
hardStopTimerCtx, hardStopTimerCancel := context.WithCancel(context.WithoutCancel(ctx))
1246+
defer hardStopTimerCancel()
1247+
1248+
startHardStopTimer := sync.OnceFunc(func() {
1249+
if c.config.HardStopTimeout <= 0 {
1250+
return
1251+
}
1252+
1253+
go func() {
1254+
timer := time.NewTimer(c.config.HardStopTimeout)
1255+
defer timer.Stop()
1256+
1257+
select {
1258+
case <-timer.C:
1259+
c.baseService.Logger.WarnContext(ctx, c.baseService.Name+": Hard stop timeout; setting remaining jobs to errored", slog.Duration("hard_stop_timeout", c.config.HardStopTimeout))
1260+
for _, producer := range producerList {
1261+
producer.hardStop()
1262+
}
1263+
case <-hardStopTimerCtx.Done():
1264+
}
1265+
}()
1266+
})
1267+
1268+
workCtx := c.queues.workCtx
1269+
go func() {
1270+
select {
1271+
case <-workCtx.Done():
1272+
startHardStopTimer()
1273+
case <-hardStopTimerCtx.Done():
1274+
}
1275+
}()
1276+
12181277
// If SoftStopTimeout is configured, start a timer that will cancel
1219-
// the work context (escalating to a hard stop) if producers don't
1220-
// finish in time. StopAndCancel also calls workCancel, in which case
1221-
// this timer is a harmless no-op because the context is already done.
1278+
// the work context if producers don't finish in time. Once the work
1279+
// context is cancelled, the optional hard stop timer starts.
12221280
if c.config.SoftStopTimeout > 0 {
12231281
softStopTimer := time.AfterFunc(c.config.SoftStopTimeout, func() {
12241282
c.baseService.Logger.WarnContext(ctx, c.baseService.Name+": Soft stop timeout; cancelling remaining job contexts", slog.Duration("soft_stop_timeout", c.config.SoftStopTimeout))
12251283
c.workCancel(rivercommon.ErrStop)
1284+
startHardStopTimer()
12261285
})
12271286
defer softStopTimer.Stop()
12281287
}
12291288

12301289
// On stop, have the producers stop fetching first of all.
12311290
c.baseService.Logger.DebugContext(ctx, c.baseService.Name+": Stopping producers")
1232-
startstop.StopAllParallel(producersAsServices()...)
1291+
startstop.StopAllParallel(producersAsServices(producerList)...)
12331292
c.baseService.Logger.DebugContext(ctx, c.baseService.Name+": All producers stopped")
1293+
hardStopTimerCancel()
12341294

12351295
c.workCancel(rivercommon.ErrStop)
12361296

@@ -1259,12 +1319,18 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
12591319
// complete before exiting. If the provided context is done before shutdown has
12601320
// completed, Stop will return immediately with the context's error.
12611321
//
1262-
// If SoftStopTimeout is configured, running job contexts will be automatically
1263-
// cancelled after the timeout elapses, escalating to a hard stop. This also
1264-
// applies when stop is initiated by cancelling the context passed to Start.
1322+
// If SoftStopTimeout is configured, jobs still running after the timeout
1323+
// elapses have their contexts cancelled.
1324+
//
1325+
// If HardStopTimeout is configured, jobs still running after SoftStopTimeout
1326+
// and HardStopTimeout have elapsed (i.e. waited for jobs to stop gracefully
1327+
// before cancelling, then waited again for them to stop on cancel) are forced
1328+
// into an errored state so they can be retried immediately on the next client
1329+
// start. This also applies when stop is initiated by cancelling the context
1330+
// passed to Start.
12651331
//
1266-
// There's no need to call this method if a hard stop has already been initiated
1267-
// by cancelling the context passed to Start or by calling StopAndCancel.
1332+
// There's no need to call this method if shutdown has already been initiated by
1333+
// cancelling the context passed to Start or by calling StopAndCancel.
12681334
func (c *Client[TTx]) Stop(ctx context.Context) error {
12691335
shouldStop, stopped, finalizeStop := c.baseStartStop.StopInit()
12701336
if !shouldStop {
@@ -1283,10 +1349,11 @@ func (c *Client[TTx]) Stop(ctx context.Context) error {
12831349

12841350
// StopAndCancel shuts down the client and cancels all work in progress. It is a
12851351
// more aggressive stop than Stop because the contexts for any in-progress jobs
1286-
// are cancelled. However, it still waits for jobs to complete before returning,
1287-
// even though their contexts are cancelled. If the provided context is done
1288-
// before shutdown has completed, StopAndCancel will return immediately with the
1289-
// context's error.
1352+
// are cancelled immediately. If HardStopTimeout is configured, jobs that still
1353+
// remain running after the timeout are hard-stopped; otherwise, StopAndCancel
1354+
// waits for jobs to complete even though their contexts are cancelled. If the
1355+
// provided context is done before shutdown has completed, StopAndCancel will
1356+
// return immediately with the context's error.
12901357
//
12911358
// This can also be initiated by cancelling the context passed to Start. There is
12921359
// no need to call this method if the context passed to Start is cancelled
@@ -1298,7 +1365,7 @@ func (c *Client[TTx]) Stop(ctx context.Context) error {
12981365
// graceful stop semantics without requiring manual orchestration of Stop and
12991366
// StopAndCancel.
13001367
func (c *Client[TTx]) StopAndCancel(ctx context.Context) error {
1301-
c.baseService.Logger.InfoContext(ctx, c.baseService.Name+": Hard stop started; cancelling all work")
1368+
c.baseService.Logger.InfoContext(ctx, c.baseService.Name+": Soft stop started; cancelling all work")
13021369
c.workCancel(rivercommon.ErrStop)
13031370

13041371
shouldStop, stopped, finalizeStop := c.baseStartStop.StopInit()

0 commit comments

Comments
 (0)