Skip to content

Commit 7530cde

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 78ae535 commit 7530cde

7 files changed

Lines changed: 460 additions & 60 deletions

File tree

client.go

Lines changed: 95 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,20 @@ type Config struct {
221221
// Defaults to 1 minute.
222222
JobTimeout time.Duration
223223

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

353367
// SoftStopTimeout is the maximum amount of time that the client will wait
354-
// for running jobs to finish during a stop before their contexts are
355-
// cancelled. After the timeout elapses, the client escalates to a hard stop
356-
// by cancelling the context of all running jobs. This applies regardless of
357-
// how stop is initiated — whether by calling Stop, StopAndCancel, or by
358-
// cancelling the context passed to Start.
368+
// for running jobs to finish during a graceful stop before entering soft
369+
// stop by cancelling job contexts. This applies when stop is initiated by
370+
// calling Stop or by cancelling the context passed to Start.
359371
//
360372
// In combination with signal.NotifyContext on the context passed to Start,
361373
// this can simplify graceful stop to:
@@ -366,12 +378,12 @@ type Config struct {
366378
// if err := client.Start(ctx); err != nil { ... }
367379
// <-client.Stopped()
368380
//
369-
// The signal cancels the Start context, which initiates a soft stop. If
381+
// The signal cancels the Start context, which initiates a graceful stop. If
370382
// running jobs haven't finished after SoftStopTimeout, their contexts are
371-
// automatically cancelled to trigger a hard stop.
383+
// cancelled.
372384
//
373-
// StopAndCancel bypasses the timeout entirely and cancels job contexts
374-
// immediately.
385+
// StopAndCancel cancels job contexts immediately instead of waiting for
386+
// SoftStopTimeout.
375387
//
376388
// Defaults to no timeout (wait indefinitely for jobs to finish).
377389
SoftStopTimeout time.Duration
@@ -489,6 +501,7 @@ func (c *Config) WithDefaults() *Config {
489501
ErrorHandler: c.ErrorHandler,
490502
FetchCooldown: cmp.Or(c.FetchCooldown, FetchCooldownDefault),
491503
FetchPollInterval: cmp.Or(c.FetchPollInterval, FetchPollIntervalDefault),
504+
HardStopTimeout: c.HardStopTimeout,
492505
ID: valutil.ValOrDefaultFunc(c.ID, func() string { return defaultClientID(time.Now().UTC()) }),
493506
Hooks: c.Hooks,
494507
JobInsertMiddleware: c.JobInsertMiddleware,
@@ -538,6 +551,9 @@ func (c *Config) validate() error {
538551
if c.FetchPollInterval < c.FetchCooldown {
539552
return fmt.Errorf("FetchPollInterval cannot be shorter than FetchCooldown (%s)", c.FetchCooldown)
540553
}
554+
if c.HardStopTimeout < 0 {
555+
return errors.New("HardStopTimeout cannot be less than zero")
556+
}
541557
if len(c.ID) > 100 {
542558
return errors.New("ID cannot be longer than 100 characters")
543559
}
@@ -573,6 +589,9 @@ func (c *Config) validate() error {
573589
if c.Schema != "" && !postgresSchemaNameRE.MatchString(c.Schema) {
574590
return errors.New("Schema name can only contain letters, numbers, and underscores, and must start with a letter or underscore")
575591
}
592+
if c.SoftStopTimeout < 0 {
593+
return errors.New("SoftStopTimeout cannot be less than zero")
594+
}
576595

577596
for queue, queueConfig := range c.Queues {
578597
if err := queueConfig.validate(queue, c.FetchCooldown, c.FetchPollInterval); err != nil {
@@ -1075,10 +1094,12 @@ func NewClient[TTx any](driver riverdriver.Driver[TTx], config *Config) (*Client
10751094
// A graceful shutdown stops fetching new jobs but allows any previously fetched
10761095
// jobs to complete. This can be initiated with the Stop method.
10771096
//
1078-
// A more abrupt shutdown can be achieved by either cancelling the provided
1079-
// context or by calling StopAndCancel. This will not only stop fetching new
1080-
// jobs, but will also cancel the context for any currently-running jobs. If
1081-
// using StopAndCancel, there's no need to also call Stop.
1097+
// A soft stop cancels job contexts after fetching has stopped. It can be
1098+
// initiated by calling StopAndCancel, by cancelling the provided context when
1099+
// SoftStopTimeout is not configured, or by waiting for SoftStopTimeout to elapse
1100+
// during graceful stop. If HardStopTimeout is configured, jobs still running
1101+
// after that timeout will be forced into an errored state. If using
1102+
// StopAndCancel, there's no need to also call Stop.
10821103
func (c *Client[TTx]) Start(ctx context.Context) error {
10831104
fetchCtx, shouldStart, started, stopped := c.baseStartStop.StartInit(ctx)
10841105
if !shouldStart {
@@ -1092,9 +1113,13 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
10921113
// sure to take a channel reference before finishing stopped.
10931114
c.stopped = c.baseStartStop.StoppedUnsafe()
10941115

1095-
producersAsServices := func() []startstop.Service {
1116+
producers := func() []*producer {
1117+
return maputil.Values(c.producersByQueueName)
1118+
}
1119+
1120+
producersAsServices := func(producers []*producer) []startstop.Service {
10961121
return sliceutil.Map(
1097-
maputil.Values(c.producersByQueueName),
1122+
producers,
10981123
func(p *producer) startstop.Service { return p },
10991124
)
11001125
}
@@ -1148,8 +1173,8 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
11481173
// We use separate contexts for fetching and working to allow for a
11491174
// graceful stop. When SoftStopTimeout is configured, the work context
11501175
// is detached from the start context so that cancelling the start
1151-
// context initiates a soft stop (with timeout escalation) rather than
1152-
// an immediate hard stop. When SoftStopTimeout is not configured, the
1176+
// context initiates a graceful stop (with timeout escalation) rather
1177+
// than an immediate soft stop. When SoftStopTimeout is not configured, the
11531178
// work context inherits from the start context to preserve the
11541179
// existing behavior where cancelling the start context is equivalent
11551180
// to StopAndCancel.
@@ -1172,7 +1197,7 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
11721197
for _, producer := range c.producersByQueueName {
11731198
if err := producer.StartWorkContext(fetchCtx, workCtx); err != nil {
11741199
workCancel(err)
1175-
startstop.StopAllParallel(producersAsServices()...)
1200+
startstop.StopAllParallel(producersAsServices(producers())...)
11761201
stopServicesOnError()
11771202
return err
11781203
}
@@ -1194,7 +1219,7 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
11941219
// Generate producer services while c.queues.startStopMu.Lock() is still
11951220
// held. This is used for WaitAllStarted below, but don't use it elsewhere
11961221
// because new producers may have been added while the client is running.
1197-
producerServices := producersAsServices()
1222+
producerServices := producersAsServices(producers())
11981223

11991224
go func() {
12001225
// Wait for all subservices to start up before signaling our own start.
@@ -1221,22 +1246,57 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
12211246
c.queues.startStopMu.Lock()
12221247
defer c.queues.startStopMu.Unlock()
12231248

1249+
producerList := producers()
1250+
1251+
hardStopTimerCtx, hardStopTimerCancel := context.WithCancel(context.WithoutCancel(ctx))
1252+
defer hardStopTimerCancel()
1253+
1254+
startHardStopTimer := sync.OnceFunc(func() {
1255+
if c.config.HardStopTimeout <= 0 {
1256+
return
1257+
}
1258+
1259+
go func() {
1260+
timer := time.NewTimer(c.config.HardStopTimeout)
1261+
defer timer.Stop()
1262+
1263+
select {
1264+
case <-timer.C:
1265+
c.baseService.Logger.WarnContext(ctx, c.baseService.Name+": Hard stop timeout; setting remaining jobs to errored", slog.Duration("hard_stop_timeout", c.config.HardStopTimeout))
1266+
for _, producer := range producerList {
1267+
producer.hardStop()
1268+
}
1269+
case <-hardStopTimerCtx.Done():
1270+
}
1271+
}()
1272+
})
1273+
1274+
workCtx := c.queues.workCtx
1275+
go func() {
1276+
select {
1277+
case <-workCtx.Done():
1278+
startHardStopTimer()
1279+
case <-hardStopTimerCtx.Done():
1280+
}
1281+
}()
1282+
12241283
// If SoftStopTimeout is configured, start a timer that will cancel
1225-
// the work context (escalating to a hard stop) if producers don't
1226-
// finish in time. StopAndCancel also calls workCancel, in which case
1227-
// this timer is a harmless no-op because the context is already done.
1284+
// the work context if producers don't finish in time. Once the work
1285+
// context is cancelled, the optional hard stop timer starts.
12281286
if c.config.SoftStopTimeout > 0 {
12291287
softStopTimer := time.AfterFunc(c.config.SoftStopTimeout, func() {
12301288
c.baseService.Logger.WarnContext(ctx, c.baseService.Name+": Soft stop timeout; cancelling remaining job contexts", slog.Duration("soft_stop_timeout", c.config.SoftStopTimeout))
12311289
c.workCancel(rivercommon.ErrStop)
1290+
startHardStopTimer()
12321291
})
12331292
defer softStopTimer.Stop()
12341293
}
12351294

12361295
// On stop, have the producers stop fetching first of all.
12371296
c.baseService.Logger.DebugContext(ctx, c.baseService.Name+": Stopping producers")
1238-
startstop.StopAllParallel(producersAsServices()...)
1297+
startstop.StopAllParallel(producersAsServices(producerList)...)
12391298
c.baseService.Logger.DebugContext(ctx, c.baseService.Name+": All producers stopped")
1299+
hardStopTimerCancel()
12401300

12411301
c.workCancel(rivercommon.ErrStop)
12421302

@@ -1265,12 +1325,14 @@ func (c *Client[TTx]) Start(ctx context.Context) error {
12651325
// complete before exiting. If the provided context is done before shutdown has
12661326
// completed, Stop will return immediately with the context's error.
12671327
//
1268-
// If SoftStopTimeout is configured, running job contexts will be automatically
1269-
// cancelled after the timeout elapses, escalating to a hard stop. This also
1328+
// If SoftStopTimeout is configured, jobs still running after the timeout
1329+
// elapses have their contexts cancelled. If HardStopTimeout is also configured,
1330+
// jobs still running after that second timeout are forced into an errored state
1331+
// so they can be retried immediately on the next client start. This also
12701332
// applies when stop is initiated by cancelling the context passed to Start.
12711333
//
1272-
// There's no need to call this method if a hard stop has already been initiated
1273-
// by cancelling the context passed to Start or by calling StopAndCancel.
1334+
// There's no need to call this method if shutdown has already been initiated by
1335+
// cancelling the context passed to Start or by calling StopAndCancel.
12741336
func (c *Client[TTx]) Stop(ctx context.Context) error {
12751337
shouldStop, stopped, finalizeStop := c.baseStartStop.StopInit()
12761338
if !shouldStop {
@@ -1289,10 +1351,11 @@ func (c *Client[TTx]) Stop(ctx context.Context) error {
12891351

12901352
// StopAndCancel shuts down the client and cancels all work in progress. It is a
12911353
// more aggressive stop than Stop because the contexts for any in-progress jobs
1292-
// are cancelled. However, it still waits for jobs to complete before returning,
1293-
// even though their contexts are cancelled. If the provided context is done
1294-
// before shutdown has completed, StopAndCancel will return immediately with the
1295-
// context's error.
1354+
// are cancelled immediately. If HardStopTimeout is configured, jobs that still
1355+
// remain running after the timeout are hard-stopped; otherwise, StopAndCancel
1356+
// waits for jobs to complete even though their contexts are cancelled. If the
1357+
// provided context is done before shutdown has completed, StopAndCancel will
1358+
// return immediately with the context's error.
12961359
//
12971360
// This can also be initiated by cancelling the context passed to Start. There is
12981361
// no need to call this method if the context passed to Start is cancelled
@@ -1304,7 +1367,7 @@ func (c *Client[TTx]) Stop(ctx context.Context) error {
13041367
// graceful stop semantics without requiring manual orchestration of Stop and
13051368
// StopAndCancel.
13061369
func (c *Client[TTx]) StopAndCancel(ctx context.Context) error {
1307-
c.baseService.Logger.InfoContext(ctx, c.baseService.Name+": Hard stop started; cancelling all work")
1370+
c.baseService.Logger.InfoContext(ctx, c.baseService.Name+": Soft stop started; cancelling all work")
13081371
c.workCancel(rivercommon.ErrStop)
13091372

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

0 commit comments

Comments
 (0)