Skip to content

Commit 837f68d

Browse files
committed
Make jobs cancelled due to a soft stop immediately available
While working on #1289, I realized that jobs which are "soft stopped" via context cancellation are still prone to the same side effects as if they errored in any other way: * Their number of attempts is incremented. * They may be discarded if reaching max attempts. * They'll have to wait to be retried according to retry policy. This doesn't really seem right because these jobs didn't actually misbehave in any way, but were rather just slow-to-run jobs that couldn't finish cleanly inside the default stop allowance while a client was restarting or being deployed. The proper behavior should probably be more like a snooze. i.e. The soft timeout cancellation doesn't count and the jobs get a chance to be retried immediately. Here, make that change. The proper event to emit from a client under this condition is a little tricky because none of the existing `EventKindJobCompleted`, `EventKindJobFailed`, or `EventKindJobSnoozed` are a good fit for it. We instead introduce a new value for `EventKindJobInterrupted` which describes this precise case and nothing else. Because existing event stream integrations are required to react based on the existing set of enum values, it's safe to add a new one for which isn't necessary to react to (i.e. if a client does nothing about `EventKindJobInterrupted` when it receives it, that's perfectly fine).
1 parent 238776f commit 837f68d

14 files changed

Lines changed: 385 additions & 96 deletions

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- Jobs that didn't finish in time organically while a client was stopping and had to have their context cancelled no longer have this cancellation counted as an error. `attempt` is reset to the number it was before the job started working, `errors` is left unchanged, and `state` is made `available` so jobs are eligible to be retried immediately. [PR #1290](https://github.com/riverqueue/river/pull/1290)
13+
1014
## [0.43.0] - 2026-08-05
1115

1216
### Added
1317

18+
- Added `EventKindJobInterrupted`, emitted when a running job is interrupted because its client is shutting down, the job was cancelled, and has been made immediately available to be worked again. [PR #1290](https://github.com/riverqueue/river/pull/1290).
1419
- Added `JobListParams.TagsAll` and `JobListParams.TagsAny` for filtering jobs that match every or any exact tag, respectively. [PR #1339](https://github.com/riverqueue/river/pull/1339).
1520

1621
### Fixed

‎client_test.go‎

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@ func subscribe[TTx any](t *testing.T, client *Client[TTx]) <-chan *Event {
342342
EventKindJobCancelled,
343343
EventKindJobCompleted,
344344
EventKindJobFailed,
345+
EventKindJobInterrupted,
345346
EventKindJobSnoozed,
346347
EventKindQueuePaused,
347348
EventKindQueueResumed,
@@ -2766,7 +2767,6 @@ func Test_Client_SoftStopTimeout(t *testing.T) {
27662767
}))
27672768

27682769
client := runNewTestClient(ctx, t, config)
2769-
27702770
_, err := client.Insert(ctx, JobArgs{}, nil)
27712771
require.NoError(t, err)
27722772

@@ -2784,6 +2784,76 @@ func Test_Client_SoftStopTimeout(t *testing.T) {
27842784
}
27852785
})
27862786

2787+
t.Run("ErroringJobGetsFreshAttempt", func(t *testing.T) {
2788+
t.Parallel()
2789+
2790+
config := newTestConfig(t, "")
2791+
config.SoftStopTimeout = 100 * time.Millisecond
2792+
2793+
firstRunDoneChan := make(chan struct{})
2794+
jobStartedChan := make(chan int64, 2)
2795+
var runCount atomic.Int32
2796+
AddWorker(config.Workers, WorkFunc(func(ctx context.Context, job *Job[JobArgs]) error {
2797+
jobStartedChan <- job.ID
2798+
switch runCount.Add(1) {
2799+
case 1:
2800+
<-ctx.Done()
2801+
close(firstRunDoneChan)
2802+
return ctx.Err()
2803+
default:
2804+
return errors.New("real job error")
2805+
}
2806+
}))
2807+
2808+
client := runNewTestClient(ctx, t, config)
2809+
subscribeChan, cancelSubscribe := client.Subscribe(EventKindJobInterrupted)
2810+
t.Cleanup(cancelSubscribe)
2811+
2812+
insertRes, err := client.Insert(ctx, JobArgs{}, &InsertOpts{MaxAttempts: 2})
2813+
require.NoError(t, err)
2814+
2815+
jobID := riversharedtest.WaitOrTimeout(t, jobStartedChan)
2816+
require.Equal(t, insertRes.Job.ID, jobID)
2817+
2818+
require.NoError(t, client.Stop(ctx))
2819+
riversharedtest.WaitOrTimeout(t, firstRunDoneChan)
2820+
2821+
event := riversharedtest.WaitOrTimeout(t, subscribeChan)
2822+
require.NotNil(t, event)
2823+
require.Equal(t, EventKindJobInterrupted, event.Kind)
2824+
require.Equal(t, insertRes.Job.ID, event.Job.ID)
2825+
require.Equal(t, rivertype.JobStateAvailable, event.Job.State)
2826+
require.NotNil(t, event.JobStats)
2827+
2828+
jobAfter, err := client.driver.GetExecutor().JobGetByID(ctx, &riverdriver.JobGetByIDParams{ID: jobID, Schema: client.config.Schema})
2829+
require.NoError(t, err)
2830+
require.Equal(t, 0, jobAfter.Attempt)
2831+
require.Nil(t, jobAfter.FinalizedAt)
2832+
require.Equal(t, 2, jobAfter.MaxAttempts)
2833+
require.Equal(t, rivertype.JobStateAvailable, jobAfter.State)
2834+
require.WithinDuration(t, time.Now(), jobAfter.ScheduledAt, 2*time.Second)
2835+
require.Empty(t, jobAfter.Errors)
2836+
2837+
require.NoError(t, client.Start(ctx))
2838+
2839+
jobID = riversharedtest.WaitOrTimeout(t, jobStartedChan)
2840+
require.Equal(t, insertRes.Job.ID, jobID)
2841+
2842+
var jobAfterRealError *rivertype.JobRow
2843+
require.Eventually(t, func() bool {
2844+
var err error
2845+
jobAfterRealError, err = client.driver.GetExecutor().JobGetByID(ctx, &riverdriver.JobGetByIDParams{ID: jobID, Schema: client.config.Schema})
2846+
require.NoError(t, err)
2847+
return jobAfterRealError.State != rivertype.JobStateRunning
2848+
}, 5*time.Second, 10*time.Millisecond)
2849+
2850+
require.Equal(t, 1, jobAfterRealError.Attempt)
2851+
require.Equal(t, rivertype.JobStateRetryable, jobAfterRealError.State)
2852+
require.Len(t, jobAfterRealError.Errors, 1)
2853+
require.Equal(t, "real job error", jobAfterRealError.Errors[0].Error)
2854+
require.Less(t, time.Until(jobAfterRealError.ScheduledAt), 3*time.Second)
2855+
})
2856+
27872857
t.Run("SoftStopSucceedsBeforeTimeout", func(t *testing.T) {
27882858
t.Parallel()
27892859

@@ -2820,7 +2890,7 @@ func Test_Client_SoftStopTimeout(t *testing.T) {
28202890
close(jobStartedChan)
28212891
<-ctx.Done()
28222892
close(jobDoneChan)
2823-
return nil
2893+
return ctx.Err()
28242894
}))
28252895

28262896
var (
@@ -2832,6 +2902,8 @@ func Test_Client_SoftStopTimeout(t *testing.T) {
28322902

28332903
client, err := NewClient(driver, config)
28342904
require.NoError(t, err)
2905+
subscribeChan, cancelSubscribe := client.Subscribe(EventKindJobInterrupted)
2906+
t.Cleanup(cancelSubscribe)
28352907

28362908
startCtx, startCtxCancel := context.WithCancel(ctx)
28372909
defer startCtxCancel()
@@ -2854,6 +2926,12 @@ func Test_Client_SoftStopTimeout(t *testing.T) {
28542926
default:
28552927
t.Fatal("expected job to have been cancelled by soft stop timeout")
28562928
}
2929+
2930+
event := riversharedtest.WaitOrTimeout(t, subscribeChan)
2931+
require.NotNil(t, event)
2932+
require.Equal(t, EventKindJobInterrupted, event.Kind)
2933+
require.Equal(t, rivertype.JobStateAvailable, event.Job.State)
2934+
require.NotNil(t, event.JobStats)
28572935
})
28582936
}
28592937

‎event.go‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ const (
2323
// differentiate each type of occurrence.
2424
EventKindJobFailed EventKind = "job_failed"
2525

26+
// EventKindJobInterrupted occurs when a running job is interrupted because
27+
// its client is shutting down and is made immediately available to be worked
28+
// again. An interruption does not consume an attempt or add an attempt error.
29+
EventKindJobInterrupted EventKind = "job_interrupted"
30+
2631
// EventKindJobSnoozed occurs when a job is snoozed.
2732
EventKindJobSnoozed EventKind = "job_snoozed"
2833

@@ -37,12 +42,13 @@ const (
3742
// exported because end users should have no way of subscribing to all known
3843
// kinds for forward compatibility reasons.
3944
var allKinds = map[EventKind]struct{}{ //nolint:gochecknoglobals
40-
EventKindJobCancelled: {},
41-
EventKindJobCompleted: {},
42-
EventKindJobFailed: {},
43-
EventKindJobSnoozed: {},
44-
EventKindQueuePaused: {},
45-
EventKindQueueResumed: {},
45+
EventKindJobCancelled: {},
46+
EventKindJobCompleted: {},
47+
EventKindJobFailed: {},
48+
EventKindJobInterrupted: {},
49+
EventKindJobSnoozed: {},
50+
EventKindQueuePaused: {},
51+
EventKindQueueResumed: {},
4652
}
4753

4854
// Event wraps an event that occurred within a River client, like a job being

‎internal/jobcompleter/job_completer.go‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ type SubscribeFunc func(update CompleterJobUpdated)
4545
type CompleterJobUpdated struct {
4646
Job *rivertype.JobRow
4747
JobStats *jobstats.JobStatistics
48-
Snoozed bool
48+
Reason riverdriver.JobSetStateReason
4949
}
5050

5151
type InlineCompleter struct {
@@ -104,7 +104,7 @@ func (c *InlineCompleter) JobSetStateIfRunning(ctx context.Context, stats *jobst
104104
c.subscribeCh <- []CompleterJobUpdated{{
105105
Job: jobs[0],
106106
JobStats: stats,
107-
Snoozed: params.Snoozed,
107+
Reason: params.Reason,
108108
}}
109109

110110
return nil
@@ -219,7 +219,7 @@ func (c *AsyncCompleter) JobSetStateIfRunning(ctx context.Context, stats *jobsta
219219
c.subscribeCh <- []CompleterJobUpdated{{
220220
Job: jobs[0],
221221
JobStats: stats,
222-
Snoozed: params.Snoozed,
222+
Reason: params.Reason,
223223
}}
224224

225225
return nil
@@ -503,19 +503,21 @@ func (c *BatchCompleter) handleBatch(ctx context.Context) error {
503503

504504
var (
505505
completeTime = c.Time.Now()
506-
events = make([]CompleterJobUpdated, len(jobRows))
506+
events = make([]CompleterJobUpdated, 0, len(jobRows))
507507
)
508-
for i, jobRow := range jobRows {
508+
for _, jobRow := range jobRows {
509509
setState := setStateBatch[jobRow.ID]
510510
setState.Stats.CompleteDuration = completeTime.Sub(setState.StartTime)
511-
events[i] = CompleterJobUpdated{
511+
events = append(events, CompleterJobUpdated{
512512
Job: jobRow,
513513
JobStats: setState.Stats,
514-
Snoozed: setState.Params.Snoozed,
515-
}
514+
Reason: setState.Params.Reason,
515+
})
516516
}
517517

518-
c.subscribeCh <- events
518+
if len(events) > 0 {
519+
c.subscribeCh <- events
520+
}
519521

520522
func() {
521523
c.setStateParamsMu.Lock()

‎internal/jobcompleter/job_completer_test.go‎

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -326,9 +326,9 @@ func testCompleterSubscribe(t *testing.T, constructor func(schema string, exec r
326326
completer.Stop() // closes subscribeChan
327327

328328
updates := riversharedtest.WaitOrTimeoutN(t, jobUpdateChan, 4)
329-
for range 4 {
330-
require.Equal(t, rivertype.JobStateCompleted, updates[0].Job.State)
331-
require.False(t, updates[0].Snoozed)
329+
for _, update := range updates {
330+
require.Equal(t, rivertype.JobStateCompleted, update.Job.State)
331+
require.Equal(t, riverdriver.JobSetStateReasonCompleted, update.Reason)
332332
}
333333
go completer.Stop()
334334
// drain all remaining jobs
@@ -1008,15 +1008,17 @@ func testCompleter[TCompleter JobCompleter](
10081008
job5 = testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{Schema: bundle.schema, State: ptrutil.Ptr(rivertype.JobStateRunning)})
10091009
job6 = testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{Schema: bundle.schema, State: ptrutil.Ptr(rivertype.JobStateRunning)})
10101010
job7 = testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{Schema: bundle.schema, State: ptrutil.Ptr(rivertype.JobStateRunning)})
1011+
job8 = testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{Schema: bundle.schema, State: ptrutil.Ptr(rivertype.JobStateRunning)})
10111012
)
10121013

10131014
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateCancelled(job1.ID, time.Now(), []byte("{}"), nil)))
10141015
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateCompleted(job2.ID, time.Now(), nil)))
10151016
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateDiscarded(job3.ID, time.Now(), []byte("{}"), nil)))
10161017
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateErrorAvailable(job4.ID, time.Now(), []byte("{}"), nil)))
10171018
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateErrorRetryable(job5.ID, time.Now(), []byte("{}"), nil)))
1018-
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateSnoozed(job6.ID, time.Now(), 10, nil)))
1019-
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateSnoozedAvailable(job7.ID, time.Now(), 10, nil)))
1019+
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateInterrupted(job6.ID, time.Now(), job6.Attempt, nil)))
1020+
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateSnoozed(job7.ID, time.Now(), 10, nil)))
1021+
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateSnoozedAvailable(job8.ID, time.Now(), 10, nil)))
10201022

10211023
completer.Stop()
10221024

@@ -1025,8 +1027,9 @@ func testCompleter[TCompleter JobCompleter](
10251027
requireState(t, bundle, job3.ID, rivertype.JobStateDiscarded)
10261028
requireState(t, bundle, job4.ID, rivertype.JobStateAvailable)
10271029
requireState(t, bundle, job5.ID, rivertype.JobStateRetryable)
1028-
requireState(t, bundle, job6.ID, rivertype.JobStateScheduled)
1029-
requireState(t, bundle, job7.ID, rivertype.JobStateAvailable)
1030+
requireState(t, bundle, job6.ID, rivertype.JobStateAvailable)
1031+
requireState(t, bundle, job7.ID, rivertype.JobStateScheduled)
1032+
requireState(t, bundle, job8.ID, rivertype.JobStateAvailable)
10301033
})
10311034

10321035
t.Run("Subscription", func(t *testing.T) {
@@ -1037,23 +1040,23 @@ func testCompleter[TCompleter JobCompleter](
10371040
var (
10381041
job1 = testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{Schema: bundle.schema, State: ptrutil.Ptr(rivertype.JobStateRunning)})
10391042
job2 = testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{Schema: bundle.schema, State: ptrutil.Ptr(rivertype.JobStateRunning)})
1043+
job3 = testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{Schema: bundle.schema, State: ptrutil.Ptr(rivertype.JobStateRunning)})
10401044
)
10411045

10421046
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateCompleted(job1.ID, time.Now(), nil)))
1043-
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateSnoozedAvailable(job2.ID, time.Now(), job2.Attempt, nil)))
1047+
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateInterrupted(job2.ID, time.Now(), job2.Attempt, nil)))
1048+
require.NoError(t, completer.JobSetStateIfRunning(ctx, &jobstats.JobStatistics{}, riverdriver.JobSetStateSnoozedAvailable(job3.ID, time.Now(), job3.Attempt, nil)))
10441049

10451050
completer.Stop()
10461051

10471052
// Unfortunately we have to do this awkward loop to wait for all updates
10481053
// because updates are sent through the channel as batches. The sync and
1049-
// async completer don't work in batches and therefore send batches of
1050-
// one item each while the batch completer will send both. Put
1051-
// otherwise, expect the sync and async completers to iterate this loop
1052-
// twice and batch completer to iterate it once.
1054+
// async completers send batches of one item each, while the batch
1055+
// completer may group updates.
10531056
var jobUpdates []CompleterJobUpdated
10541057
for {
10551058
jobUpdates = append(jobUpdates, riversharedtest.WaitOrTimeout(t, bundle.subscribeCh)...)
1056-
if len(jobUpdates) >= 2 {
1059+
if len(jobUpdates) >= 3 {
10571060
break
10581061
}
10591062
}
@@ -1070,10 +1073,13 @@ func testCompleter[TCompleter JobCompleter](
10701073

10711074
job1Update := findUpdate(job1.ID)
10721075
require.Equal(t, rivertype.JobStateCompleted, job1Update.Job.State)
1073-
require.False(t, job1Update.Snoozed)
1076+
require.Equal(t, riverdriver.JobSetStateReasonCompleted, job1Update.Reason)
10741077
job2Update := findUpdate(job2.ID)
10751078
require.Equal(t, rivertype.JobStateAvailable, job2Update.Job.State)
1076-
require.True(t, job2Update.Snoozed)
1079+
require.Equal(t, riverdriver.JobSetStateReasonInterrupted, job2Update.Reason)
1080+
job3Update := findUpdate(job3.ID)
1081+
require.Equal(t, rivertype.JobStateAvailable, job3Update.Job.State)
1082+
require.Equal(t, riverdriver.JobSetStateReasonSnoozed, job3Update.Reason)
10771083
})
10781084

10791085
t.Run("MultipleCycles", func(t *testing.T) {

‎internal/jobexecutor/job_executor.go‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/riverqueue/river/internal/jobcompleter"
1919
"github.com/riverqueue/river/internal/jobstats"
2020
"github.com/riverqueue/river/internal/pluginlookup"
21+
"github.com/riverqueue/river/internal/rivercommon"
2122
"github.com/riverqueue/river/internal/workunit"
2223
"github.com/riverqueue/river/riverdriver"
2324
"github.com/riverqueue/river/rivershared/baseservice"
@@ -450,6 +451,7 @@ func (e *JobExecutor) reportError(ctx context.Context, jobRow *rivertype.JobRow,
450451
cancelJob bool
451452
cancelErr *rivertype.JobCancelError
452453
)
454+
softStopped := isSoftStopCancelError(ctx, res.Err)
453455

454456
logAttrs := []any{
455457
slog.String("error", res.ErrorStr()),
@@ -461,6 +463,8 @@ func (e *JobExecutor) reportError(ctx context.Context, jobRow *rivertype.JobRow,
461463
case errors.As(res.Err, &cancelErr):
462464
cancelJob = true
463465
e.Logger.DebugContext(ctx, e.Name+": Job cancelled explicitly", logAttrs...)
466+
case softStopped:
467+
e.Logger.InfoContext(ctx, e.Name+": Job stopped due to client shutdown; retrying", logAttrs...)
464468
case res.Err != nil:
465469
if jobRow.Attempt >= jobRow.MaxAttempts {
466470
e.Logger.InfoContext(ctx, e.Name+": Job errored", logAttrs...)
@@ -471,11 +475,21 @@ func (e *JobExecutor) reportError(ctx context.Context, jobRow *rivertype.JobRow,
471475
e.Logger.InfoContext(ctx, e.Name+": Job panicked", logAttrs...)
472476
}
473477

474-
if e.ErrorHandler != nil && !cancelJob {
478+
if e.ErrorHandler != nil && !cancelJob && !softStopped {
475479
// Error handlers also have an opportunity to cancel the job.
476480
cancelJob = e.invokeErrorHandler(ctx, res)
477481
}
478482

483+
now := e.Time.Now()
484+
485+
if softStopped {
486+
params := riverdriver.JobSetStateInterrupted(jobRow.ID, now, max(jobRow.Attempt-1, 0), metadataUpdates)
487+
if err := e.Completer.JobSetStateIfRunning(ctx, e.stats, params); err != nil {
488+
e.Logger.ErrorContext(ctx, e.Name+": Failed to make soft-stopped job available", logAttrs...)
489+
}
490+
return
491+
}
492+
479493
attemptErr := rivertype.AttemptError{
480494
At: e.start,
481495
Attempt: jobRow.Attempt,
@@ -489,8 +503,6 @@ func (e *JobExecutor) reportError(ctx context.Context, jobRow *rivertype.JobRow,
489503
return
490504
}
491505

492-
now := e.Time.Now()
493-
494506
if cancelJob {
495507
if err := e.Completer.JobSetStateIfRunning(ctx, e.stats, riverdriver.JobSetStateCancelled(jobRow.ID, now, errData, metadataUpdates)); err != nil {
496508
e.Logger.ErrorContext(ctx, e.Name+": Failed to cancel job and report error", logAttrs...)
@@ -539,6 +551,15 @@ func (e *JobExecutor) reportError(ctx context.Context, jobRow *rivertype.JobRow,
539551
}
540552
}
541553

554+
// isSoftStopCancelError reports whether a worker returned because the client
555+
// was stopping and cancelled its job context. The context cause distinguishes
556+
// client stop cancellation from ordinary worker cancellation or timeouts.
557+
func isSoftStopCancelError(ctx context.Context, err error) bool {
558+
return err != nil &&
559+
errors.Is(context.Cause(ctx), rivercommon.ErrStop) &&
560+
(errors.Is(err, context.Canceled) || errors.Is(err, rivercommon.ErrStop))
561+
}
562+
542563
type withJobsAndErrorsByID interface {
543564
ErrorsByID() map[int64]error
544565
Jobs() []*rivertype.JobRow

0 commit comments

Comments
 (0)