Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion example_graceful_shutdown_stop_and_cancel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func Example_gracefulShutdownStopAndCancel() {
river.AddWorker(workers, &WaitsForCancelOnlyWorker{jobStarted: jobStarted})

riverClient, err := river.NewClient(riverpgxv5.New(dbPool), initTestConfig(ctx, dbPool, &river.Config{
Logger: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError, ReplaceAttr: slogutil.NoLevelTimeJobID})),
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError, ReplaceAttr: slogutil.NoLevelTimeJobID})),
Queues: map[string]river.QueueConfig{
river.QueueDefault: {MaxWorkers: 100},
},
Expand Down
2 changes: 1 addition & 1 deletion example_graceful_shutdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func Example_gracefulShutdown() {
river.AddWorker(workers, &WaitsForCancelOnlyWorker{jobStarted: jobStarted})

riverClient, err := river.NewClient(riverpgxv5.New(dbPool), initTestConfig(ctx, dbPool, &river.Config{
Logger: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError, ReplaceAttr: slogutil.NoLevelTimeJobID})),
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError, ReplaceAttr: slogutil.NoLevelTimeJobID})),
Queues: map[string]river.QueueConfig{
river.QueueDefault: {MaxWorkers: 100},
},
Expand Down
7 changes: 6 additions & 1 deletion internal/maintenance/job_scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ func NewJobScheduler(archetype *baseservice.Archetype, config *JobSchedulerConfi
})
}

func (s *JobScheduler) Start(ctx context.Context) error { //nolint:dupl
func (s *JobScheduler) Start(ctx context.Context) error {
ctx, shouldStart, started, stopped := s.StartInit(ctx)
if !shouldStart {
return nil
Expand All @@ -132,6 +132,11 @@ func (s *JobScheduler) Start(ctx context.Context) error { //nolint:dupl

res, err := s.runOnce(ctx)
if err != nil {
// Cancellation can surface as a driver-specific error, like a
// socket write timeout, instead of wrapping context.Canceled.
if ctx.Err() != nil {
return
}
Comment on lines +135 to +139

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this generally always take precedent before we hit the context.Canceled check below?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you’re right. The context check handles scheduler cancellation first, so the extra context.Canceled guard is redundant. Removed it in #1515.

if !errors.Is(err, context.Canceled) {
s.Logger.ErrorContext(ctx, s.Name+": Error scheduling jobs", slog.String("error", err.Error()))
}
Expand Down
120 changes: 120 additions & 0 deletions internal/maintenance/job_scheduler_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package maintenance

import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
"sort"
"testing"
"time"
Expand All @@ -16,10 +20,41 @@ import (
"github.com/riverqueue/river/rivershared/riversharedtest"
"github.com/riverqueue/river/rivershared/startstoptest"
"github.com/riverqueue/river/rivershared/testfactory"
"github.com/riverqueue/river/rivershared/testsignal"
"github.com/riverqueue/river/rivershared/uniquestates"
"github.com/riverqueue/river/rivertype"
)

type jobSchedulerExecutorMock struct {
riverdriver.ExecutorTx

TestSignals jobSchedulerExecutorMockTestSignals
jobScheduleFunc func(context.Context, *riverdriver.JobScheduleParams) ([]*riverdriver.JobScheduleResult, error)
}

func (e *jobSchedulerExecutorMock) Begin(ctx context.Context) (riverdriver.ExecutorTx, error) {
return e, nil
}

func (e *jobSchedulerExecutorMock) JobSchedule(ctx context.Context, params *riverdriver.JobScheduleParams) ([]*riverdriver.JobScheduleResult, error) {
e.TestSignals.JobScheduleStarted.Signal(struct{}{})
return e.jobScheduleFunc(ctx, params)
}

func (e *jobSchedulerExecutorMock) Rollback(ctx context.Context) error {
return nil
}

type jobSchedulerExecutorMockTestSignals struct {
JobScheduleStarted testsignal.TestSignal[struct{}]
}

func (ts *jobSchedulerExecutorMockTestSignals) Init(t *testing.T) {
t.Helper()

ts.JobScheduleStarted.Init(t)
}

func TestJobScheduler(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -440,3 +475,88 @@ func TestJobScheduler(t *testing.T) {
}
})
}

func TestJobScheduler_ErrorLogging(t *testing.T) {
t.Parallel()

type testBundle struct {
exec *jobSchedulerExecutorMock
logOutput bytes.Buffer
scheduler *JobScheduler
}

// pgx can return a write timeout instead of context.Canceled when shutdown
// interrupts a query write. Preserve that error shape in the regression.
writeErr := fmt.Errorf("write failed: %w", os.ErrDeadlineExceeded)
require.NotErrorIs(t, writeErr, context.Canceled)

setup := func(t *testing.T) *testBundle {
t.Helper()

bundle := &testBundle{exec: &jobSchedulerExecutorMock{}}
bundle.exec.TestSignals.Init(t)
bundle.exec.jobScheduleFunc = func(ctx context.Context, params *riverdriver.JobScheduleParams) ([]*riverdriver.JobScheduleResult, error) {
<-ctx.Done()
return nil, writeErr
}

archetype := riversharedtest.BaseServiceArchetype(t)
archetype.Logger = slog.New(slog.NewTextHandler(&bundle.logOutput, &slog.HandlerOptions{Level: slog.LevelError}))
bundle.scheduler = NewJobScheduler(archetype, &JobSchedulerConfig{Interval: time.Millisecond}, bundle.exec)
bundle.scheduler.StaggerStartupDisable(true)
t.Cleanup(bundle.scheduler.Stop)

return bundle
}

t.Run("ContextCancellationWithoutLogging", func(t *testing.T) {
t.Parallel()

bundle := setup(t)

ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
require.NoError(t, bundle.scheduler.Start(ctx))
bundle.exec.TestSignals.JobScheduleStarted.WaitOrTimeout()

cancelFunc()
bundle.scheduler.Stop()
require.Empty(t, bundle.logOutput.String())
})

t.Run("LogsDatabaseErrors", func(t *testing.T) {
t.Parallel()

bundle := setup(t)

var numCalls int
bundle.exec.jobScheduleFunc = func(ctx context.Context, params *riverdriver.JobScheduleParams) ([]*riverdriver.JobScheduleResult, error) {
numCalls++
if numCalls > 1 {
<-ctx.Done()
return nil, ctx.Err()
}
return nil, writeErr
}

require.NoError(t, bundle.scheduler.Start(context.Background()))
bundle.exec.TestSignals.JobScheduleStarted.WaitOrTimeout()
// The next query starts only after the first error has been logged.
bundle.exec.TestSignals.JobScheduleStarted.WaitOrTimeout()
bundle.scheduler.Stop()
require.Contains(t, bundle.logOutput.String(), "Error scheduling jobs")
require.Contains(t, bundle.logOutput.String(), writeErr.Error())
})

t.Run("StopsWithoutLogging", func(t *testing.T) {
t.Parallel()

bundle := setup(t)

require.NoError(t, bundle.scheduler.Start(context.Background()))
bundle.exec.TestSignals.JobScheduleStarted.WaitOrTimeout()

bundle.scheduler.Stop()
require.Empty(t, bundle.logOutput.String())
})
}
Loading