@@ -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.
10761097func (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.
12681334func (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.
13001367func (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