@@ -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.
10821103func (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,18 @@ 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
1270- // applies when stop is initiated by cancelling the context passed to Start.
1328+ // If SoftStopTimeout is configured, jobs still running after the timeout
1329+ // elapses have their contexts cancelled.
1330+
1331+ // If HardStopTimeout is configured, jobs still running after SoftStopTimeout
1332+ // and HardStopTimeout have elapsed (i.e. waited for jobs to stop gracefully
1333+ // berfore cancelling, then waited again for them to stop on cancel) are forced
1334+ // into an errored state so they can be retried immediately on the next client
1335+ // start. This also applies when stop is initiated by cancelling the context
1336+ // passed to Start.
12711337//
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.
1338+ // There's no need to call this method if shutdown has already been initiated by
1339+ // cancelling the context passed to Start or by calling StopAndCancel.
12741340func (c * Client [TTx ]) Stop (ctx context.Context ) error {
12751341 shouldStop , stopped , finalizeStop := c .baseStartStop .StopInit ()
12761342 if ! shouldStop {
@@ -1289,10 +1355,11 @@ func (c *Client[TTx]) Stop(ctx context.Context) error {
12891355
12901356// StopAndCancel shuts down the client and cancels all work in progress. It is a
12911357// 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.
1358+ // are cancelled immediately. If HardStopTimeout is configured, jobs that still
1359+ // remain running after the timeout are hard-stopped; otherwise, StopAndCancel
1360+ // waits for jobs to complete even though their contexts are cancelled. If the
1361+ // provided context is done before shutdown has completed, StopAndCancel will
1362+ // return immediately with the context's error.
12961363//
12971364// This can also be initiated by cancelling the context passed to Start. There is
12981365// no need to call this method if the context passed to Start is cancelled
@@ -1304,7 +1371,7 @@ func (c *Client[TTx]) Stop(ctx context.Context) error {
13041371// graceful stop semantics without requiring manual orchestration of Stop and
13051372// StopAndCancel.
13061373func (c * Client [TTx ]) StopAndCancel (ctx context.Context ) error {
1307- c .baseService .Logger .InfoContext (ctx , c .baseService .Name + ": Hard stop started; cancelling all work" )
1374+ c .baseService .Logger .InfoContext (ctx , c .baseService .Name + ": Soft stop started; cancelling all work" )
13081375 c .workCancel (rivercommon .ErrStop )
13091376
13101377 shouldStop , stopped , finalizeStop := c .baseStartStop .StopInit ()
0 commit comments