Skip to content

Commit c5151af

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 f2512a7 commit c5151af

7 files changed

Lines changed: 451 additions & 59 deletions

File tree

client.go

Lines changed: 93 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,18 @@ type Config struct {
221221
// Jobs may have their own specific hooks by implementing JobArgsWithHooks.
222222
Hooks []rivertype.Hook
223223

224+
// HardStopTimeout is the maximum amount of time that the client will wait
225+
// after job contexts are cancelled during shutdown before forcing jobs still
226+
// running to an errored state. This hard stop phase lets jobs be retried
227+
// immediately on the next client start instead of waiting for rescue.
228+
//
229+
// The timer starts only after a soft stop has begun by cancelling job
230+
// contexts, like after SoftStopTimeout elapses, StopAndCancel is called, or
231+
// the Start context is cancelled without SoftStopTimeout configured.
232+
//
233+
// Defaults to no timeout (hard stop disabled).
234+
HardStopTimeout time.Duration
235+
224236
// Logger is the structured logger to use for logging purposes. If none is
225237
// specified, logs will be emitted to STDOUT with messages at warn level
226238
// or higher.
@@ -330,11 +342,9 @@ type Config struct {
330342
Schema string
331343

332344
// SoftStopTimeout is the maximum amount of time that the client will wait
333-
// for running jobs to finish during a stop before their contexts are
334-
// cancelled. After the timeout elapses, the client escalates to a hard stop
335-
// by cancelling the context of all running jobs. This applies regardless of
336-
// how stop is initiated — whether by calling Stop, StopAndCancel, or by
337-
// cancelling the context passed to Start.
345+
// for running jobs to finish during a graceful stop before entering soft
346+
// stop by cancelling job contexts. This applies when stop is initiated by
347+
// calling Stop or by cancelling the context passed to Start.
338348
//
339349
// In combination with signal.NotifyContext on the context passed to Start,
340350
// this can simplify graceful stop to:
@@ -345,12 +355,12 @@ type Config struct {
345355
// if err := client.Start(ctx); err != nil { ... }
346356
// <-client.Stopped()
347357
//
348-
// The signal cancels the Start context, which initiates a soft stop. If
358+
// The signal cancels the Start context, which initiates a graceful stop. If
349359
// running jobs haven't finished after SoftStopTimeout, their contexts are
350-
// automatically cancelled to trigger a hard stop.
360+
// cancelled.
351361
//
352-
// StopAndCancel bypasses the timeout entirely and cancels job contexts
353-
// immediately.
362+
// StopAndCancel cancels job contexts immediately instead of waiting for
363+
// SoftStopTimeout.
354364
//
355365
// Defaults to no timeout (wait indefinitely for jobs to finish).
356366
SoftStopTimeout time.Duration
@@ -468,6 +478,7 @@ func (c *Config) WithDefaults() *Config {
468478
ErrorHandler: c.ErrorHandler,
469479
FetchCooldown: cmp.Or(c.FetchCooldown, FetchCooldownDefault),
470480
FetchPollInterval: cmp.Or(c.FetchPollInterval, FetchPollIntervalDefault),
481+
HardStopTimeout: c.HardStopTimeout,
471482
ID: valutil.ValOrDefaultFunc(c.ID, func() string { return defaultClientID(time.Now().UTC()) }),
472483
Hooks: c.Hooks,
473484
JobInsertMiddleware: c.JobInsertMiddleware,
@@ -515,6 +526,9 @@ func (c *Config) validate() error {
515526
if c.FetchPollInterval < c.FetchCooldown {
516527
return fmt.Errorf("FetchPollInterval cannot be shorter than FetchCooldown (%s)", c.FetchCooldown)
517528
}
529+
if c.HardStopTimeout < 0 {
530+
return errors.New("HardStopTimeout cannot be less than zero")
531+
}
518532
if len(c.ID) > 100 {
519533
return errors.New("ID cannot be longer than 100 characters")
520534
}
@@ -547,6 +561,9 @@ func (c *Config) validate() error {
547561
if c.Schema != "" && !postgresSchemaNameRE.MatchString(c.Schema) {
548562
return errors.New("Schema name can only contain letters, numbers, and underscores, and must start with a letter or underscore")
549563
}
564+
if c.SoftStopTimeout < 0 {
565+
return errors.New("SoftStopTimeout cannot be less than zero")
566+
}
550567

551568
for queue, queueConfig := range c.Queues {
552569
if err := queueConfig.validate(queue, c.FetchCooldown, c.FetchPollInterval); err != nil {
@@ -1048,10 +1065,12 @@ func NewClient[TTx any](driver riverdriver.Driver[TTx], config *Config) (*Client
10481065
// A graceful shutdown stops fetching new jobs but allows any previously fetched
10491066
// jobs to complete. This can be initiated with the Stop method.
10501067
//
1051-
// A more abrupt shutdown can be achieved by either cancelling the provided
1052-
// context or by calling StopAndCancel. This will not only stop fetching new
1053-
// jobs, but will also cancel the context for any currently-running jobs. If
1054-
// using StopAndCancel, there's no need to also call Stop.
1068+
// A soft stop cancels job contexts after fetching has stopped. It can be
1069+
// initiated by calling StopAndCancel, by cancelling the provided context when
1070+
// SoftStopTimeout is not configured, or by waiting for SoftStopTimeout to elapse
1071+
// during graceful stop. If HardStopTimeout is configured, jobs still running
1072+
// after that timeout will be forced into an errored state. If using
1073+
// StopAndCancel, there's no need to also call Stop.
10551074
func (c *Client[TTx]) Start(ctx context.Context) error {
10561075
fetchCtx, shouldStart, started, stopped := c.baseStartStop.StartInit(ctx)
10571076
if !shouldStart {
@@ -1065,9 +1084,13 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
10651084
// sure to take a channel reference before finishing stopped.
10661085
c.stopped = c.baseStartStop.StoppedUnsafe()
10671086

1068-
producersAsServices := func() []startstop.Service {
1087+
producers := func() []*producer {
1088+
return maputil.Values(c.producersByQueueName)
1089+
}
1090+
1091+
producersAsServices := func(producers []*producer) []startstop.Service {
10691092
return sliceutil.Map(
1070-
maputil.Values(c.producersByQueueName),
1093+
producers,
10711094
func(p *producer) startstop.Service { return p },
10721095
)
10731096
}
@@ -1121,8 +1144,8 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
11211144
// We use separate contexts for fetching and working to allow for a
11221145
// graceful stop. When SoftStopTimeout is configured, the work context
11231146
// is detached from the start context so that cancelling the start
1124-
// context initiates a soft stop (with timeout escalation) rather than
1125-
// an immediate hard stop. When SoftStopTimeout is not configured, the
1147+
// context initiates a graceful stop (with timeout escalation) rather
1148+
// than an immediate soft stop. When SoftStopTimeout is not configured, the
11261149
// work context inherits from the start context to preserve the
11271150
// existing behavior where cancelling the start context is equivalent
11281151
// to StopAndCancel.
@@ -1145,7 +1168,7 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
11451168
for _, producer := range c.producersByQueueName {
11461169
if err := producer.StartWorkContext(fetchCtx, workCtx); err != nil {
11471170
workCancel(err)
1148-
startstop.StopAllParallel(producersAsServices()...)
1171+
startstop.StopAllParallel(producersAsServices(producers())...)
11491172
stopServicesOnError()
11501173
return err
11511174
}
@@ -1167,7 +1190,7 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
11671190
// Generate producer services while c.queues.startStopMu.Lock() is still
11681191
// held. This is used for WaitAllStarted below, but don't use it elsewhere
11691192
// because new producers may have been added while the client is running.
1170-
producerServices := producersAsServices()
1193+
producerServices := producersAsServices(producers())
11711194

11721195
go func() {
11731196
// Wait for all subservices to start up before signaling our own start.
@@ -1194,22 +1217,57 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
11941217
c.queues.startStopMu.Lock()
11951218
defer c.queues.startStopMu.Unlock()
11961219

1220+
producerList := producers()
1221+
1222+
hardStopTimerCtx, hardStopTimerCancel := context.WithCancel(context.WithoutCancel(ctx))
1223+
defer hardStopTimerCancel()
1224+
1225+
startHardStopTimer := sync.OnceFunc(func() {
1226+
if c.config.HardStopTimeout <= 0 {
1227+
return
1228+
}
1229+
1230+
go func() {
1231+
timer := time.NewTimer(c.config.HardStopTimeout)
1232+
defer timer.Stop()
1233+
1234+
select {
1235+
case <-timer.C:
1236+
c.baseService.Logger.WarnContext(ctx, c.baseService.Name+": Hard stop timeout; setting remaining jobs to errored", slog.Duration("hard_stop_timeout", c.config.HardStopTimeout))
1237+
for _, producer := range producerList {
1238+
producer.hardStop()
1239+
}
1240+
case <-hardStopTimerCtx.Done():
1241+
}
1242+
}()
1243+
})
1244+
1245+
workCtx := c.queues.workCtx
1246+
go func() {
1247+
select {
1248+
case <-workCtx.Done():
1249+
startHardStopTimer()
1250+
case <-hardStopTimerCtx.Done():
1251+
}
1252+
}()
1253+
11971254
// If SoftStopTimeout is configured, start a timer that will cancel
1198-
// the work context (escalating to a hard stop) if producers don't
1199-
// finish in time. StopAndCancel also calls workCancel, in which case
1200-
// this timer is a harmless no-op because the context is already done.
1255+
// the work context if producers don't finish in time. Once the work
1256+
// context is cancelled, the optional hard stop timer starts.
12011257
if c.config.SoftStopTimeout > 0 {
12021258
softStopTimer := time.AfterFunc(c.config.SoftStopTimeout, func() {
12031259
c.baseService.Logger.WarnContext(ctx, c.baseService.Name+": Soft stop timeout; cancelling remaining job contexts", slog.Duration("soft_stop_timeout", c.config.SoftStopTimeout))
12041260
c.workCancel(rivercommon.ErrStop)
1261+
startHardStopTimer()
12051262
})
12061263
defer softStopTimer.Stop()
12071264
}
12081265

12091266
// On stop, have the producers stop fetching first of all.
12101267
c.baseService.Logger.DebugContext(ctx, c.baseService.Name+": Stopping producers")
1211-
startstop.StopAllParallel(producersAsServices()...)
1268+
startstop.StopAllParallel(producersAsServices(producerList)...)
12121269
c.baseService.Logger.DebugContext(ctx, c.baseService.Name+": All producers stopped")
1270+
hardStopTimerCancel()
12131271

12141272
c.workCancel(rivercommon.ErrStop)
12151273

@@ -1238,12 +1296,14 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
12381296
// complete before exiting. If the provided context is done before shutdown has
12391297
// completed, Stop will return immediately with the context's error.
12401298
//
1241-
// If SoftStopTimeout is configured, running job contexts will be automatically
1242-
// cancelled after the timeout elapses, escalating to a hard stop. This also
1299+
// If SoftStopTimeout is configured, jobs still running after the timeout
1300+
// elapses have their contexts cancelled. If HardStopTimeout is also configured,
1301+
// jobs still running after that second timeout are forced into an errored state
1302+
// so they can be retried immediately on the next client start. This also
12431303
// applies when stop is initiated by cancelling the context passed to Start.
12441304
//
1245-
// There's no need to call this method if a hard stop has already been initiated
1246-
// by cancelling the context passed to Start or by calling StopAndCancel.
1305+
// There's no need to call this method if shutdown has already been initiated by
1306+
// cancelling the context passed to Start or by calling StopAndCancel.
12471307
func (c *Client[TTx]) Stop(ctx context.Context) error {
12481308
shouldStop, stopped, finalizeStop := c.baseStartStop.StopInit()
12491309
if !shouldStop {
@@ -1262,10 +1322,11 @@ func (c *Client[TTx]) Stop(ctx context.Context) error {
12621322

12631323
// StopAndCancel shuts down the client and cancels all work in progress. It is a
12641324
// more aggressive stop than Stop because the contexts for any in-progress jobs
1265-
// are cancelled. However, it still waits for jobs to complete before returning,
1266-
// even though their contexts are cancelled. If the provided context is done
1267-
// before shutdown has completed, StopAndCancel will return immediately with the
1268-
// context's error.
1325+
// are cancelled immediately. If HardStopTimeout is configured, jobs that still
1326+
// remain running after the timeout are hard-stopped; otherwise, StopAndCancel
1327+
// waits for jobs to complete even though their contexts are cancelled. If the
1328+
// provided context is done before shutdown has completed, StopAndCancel will
1329+
// return immediately with the context's error.
12691330
//
12701331
// This can also be initiated by cancelling the context passed to Start. There is
12711332
// no need to call this method if the context passed to Start is cancelled
@@ -1277,7 +1338,7 @@ func (c *Client[TTx]) Stop(ctx context.Context) error {
12771338
// graceful stop semantics without requiring manual orchestration of Stop and
12781339
// StopAndCancel.
12791340
func (c *Client[TTx]) StopAndCancel(ctx context.Context) error {
1280-
c.baseService.Logger.InfoContext(ctx, c.baseService.Name+": Hard stop started; cancelling all work")
1341+
c.baseService.Logger.InfoContext(ctx, c.baseService.Name+": Soft stop started; cancelling all work")
12811342
c.workCancel(rivercommon.ErrStop)
12821343

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

0 commit comments

Comments
 (0)