@@ -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.
10551074func (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.
12471307func (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.
12791340func (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