Skip to content
Closed
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 periodic_job_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func TestNeverSchedule(t *testing.T) {
require.False(t, next.Before(now))
// use an arbitrary duration to check that
// the next schedule is far in the future
require.True(t, next.Year()-now.Year() > 1000)
require.Greater(t, next.Year()-now.Year(), 1000)

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.

Ugh, lint auto correction.

})
}

Expand Down
51 changes: 51 additions & 0 deletions rivertest/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package rivertest
import (
"context"
"encoding/json"
"errors"
"sync/atomic"
"testing"
"time"
Expand Down Expand Up @@ -77,12 +78,62 @@ func (w *Worker[T, TTx]) Work(ctx context.Context, tb testing.TB, args T, opts *
return w.WorkJob(ctx, tb, job)
}

// WorkTx allocates a synthetic job with the provided arguments and optional
// insert options, then works it in the given transaction.
func (w *Worker[T, TTx]) WorkTx(ctx context.Context, tb testing.TB, tx TTx, args T, opts *river.InsertOpts) error {
tb.Helper()
job := makeJobFromArgs(tb, w.config, args, opts)
return w.WorkJobTx(ctx, tb, tx, job)
}

// WorkJob works the provided job. The job must be constructed to be a realistic
// job using external logic prior to calling this method. Unlike the other
// variants, this method offers total control over the job's attributes.
func (w *Worker[T, TTx]) WorkJob(ctx context.Context, tb testing.TB, job *river.Job[T]) error {
tb.Helper()

var exec riverdriver.Executor
if w.client.Driver().HasPool() {
exec = w.client.Driver().GetExecutor()
}

return w.workJobExec(ctx, tb, exec, job)
}

// WorkJobTx works the provided job in the given transaction. The job must be
// constructed to be a realistic job using external logic prior to calling this
// method. Unlike the other variants, this method offers total control over the
// job's attributes.
func (w *Worker[T, TTx]) WorkJobTx(ctx context.Context, tb testing.TB, tx TTx, job *river.Job[T]) error {
tb.Helper()
return w.workJobExec(ctx, tb, w.client.Driver().UnwrapExecutor(tx), job)
}

func (w *Worker[T, TTx]) workJobExec(ctx context.Context, tb testing.TB, exec riverdriver.Executor, job *river.Job[T]) error {
tb.Helper()

// Try to transition an existing job row to running, but also tolerate the
// row not existing in the database. Most of the time you don't need to
// insert a real job row to use this function (it's only necessary if you
// want to use `JobCompleteTx`).
if exec != nil {
updatedJobRow, err := exec.JobUpdate(ctx, &riverdriver.JobUpdateParams{
ID: job.ID,
StateDoUpdate: true,
State: rivertype.JobStateRunning,
})
if err != nil && !errors.Is(err, rivertype.ErrNotFound) {
return err
}
if updatedJobRow != nil {
job.JobRow = updatedJobRow
}
}
Comment on lines +119 to +131

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.

The docs at the top of the file about not touching the database are no longer accurate.

On a bigger note though, I'm wondering if we're thinking about this the right way. The original vision here was to not hit the DB which is why we're synthetically creating fake jobs instead of inserting and then working them. If we're going to start making DB queries in most cases regardless of whether it's a real job or a fake one, is that original design still reasonable?

I'm also a little worried about the potential to introduce test flakiness by attempting to update a job whose ID was synthetically generated from the in-memory sequence as it may correspond to a real (but different) job in the table.

Perhaps we should just go all in by always using transactions and always inserting real job records? We could even leverage the inline completer after execution. I think that might resolve any potential functionality issues I can think of—although we'd probably need to extract some of the executor logic to be able to reuse it here.

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.

If we go all in on always inserting real jobs, we can use the actual client for insert and not have to worry about all the code that's trying to recreate the appropriate default options:

diff --git a/rivertest/worker.go b/rivertest/worker.go
index 947904d..aa7cb12 100644
--- a/rivertest/worker.go
+++ b/rivertest/worker.go
@@ -70,10 +70,29 @@ func NewWorker[T river.JobArgs, TTx any](tb testing.TB, driver riverdriver.Drive
 	}
 }
 
+func (w *Worker[T, TTx]) insertJob(ctx context.Context, tb testing.TB, tx TTx, args T, opts *river.InsertOpts) *river.Job[T] {
+	tb.Helper()
+
+	result, err := w.client.InsertTx(ctx, tx, args, opts)
+	if err != nil {
+		tb.Fatalf("failed to insert job: %s", err)
+	}
+	var decodedArgs T
+	if err := json.Unmarshal(result.Job.EncodedArgs, &decodedArgs); err != nil {
+		tb.Fatalf("failed to unmarshal args: %s", err)
+	}
+
+	return &river.Job[T]{
+		Args:   decodedArgs,
+		JobRow: result.Job,
+	}
+}
+
 // Work allocates a synthetic job with the provided arguments and optional
 // insert options, then works it.
 func (w *Worker[T, TTx]) Work(ctx context.Context, tb testing.TB, args T, opts *river.InsertOpts) error {
 	tb.Helper()
+
 	job := makeJobFromArgs(tb, w.config, args, opts)
 	return w.WorkJob(ctx, tb, job)
 }
@@ -82,7 +101,8 @@ func (w *Worker[T, TTx]) Work(ctx context.Context, tb testing.TB, args T, opts *
 // insert options, then works it in the given transaction.
 func (w *Worker[T, TTx]) WorkTx(ctx context.Context, tb testing.TB, tx TTx, args T, opts *river.InsertOpts) error {
 	tb.Helper()
-	job := makeJobFromArgs(tb, w.config, args, opts)
+
+	job := w.insertJob(ctx, tb, tx, args, opts)
 	return w.WorkJobTx(ctx, tb, tx, job)
 }

This might not be compatible with the non-tx forms unless we have a way to get the underlying TTx from the ExecutorTx once you GetExecutor().Begin(). But these APIs have existed for a day so I am definitely ok with some fast fixes on this front.

The one thing I can think of that might be hard to test this way is unique jobs: it's conceivable that you could test the same job in two different concurrent tests, and you need to make sure those don't prevent each other from inserting. You'd need to skip uniqueness for these test jobs I think.

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.

Yeah that seems to make sense to me. Enough code smells have dropped out of doing this PR that it seems the original approach could stand to be modified somewhat.


// Regardless of whether the job was actually in the database, transition it
// to running.
job.JobRow.State = rivertype.JobStateRunning

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.

Trying to think of what else should get updated here to be realistic. I guess it's all of these, and not just the state?

state = 'running',
attempt = river_job.attempt + 1,
attempted_at = now(),
attempted_by = array_append(river_job.attempted_by, @attempted_by::text)

So we should consider both setting those when updating the job, and also making sure they're set on the actual job struct.

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.

Yeah, hmm. Definitely opens a can of worms. It'd be better to be able to offload a real insert to the client/executor so we don't have to muddle with all this.


// populate river client into context:
ctx = WorkContext(ctx, w.client)
ctx = context.WithValue(ctx, execution.ContextKeyInsideTestWorker{}, true)
Expand Down
235 changes: 219 additions & 16 deletions rivertest/worker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import (
"testing"
"time"

"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"

"github.com/riverqueue/river"
"github.com/riverqueue/river/internal/dbunique"
"github.com/riverqueue/river/internal/execution"
"github.com/riverqueue/river/internal/riverinternaltest"
"github.com/riverqueue/river/riverdriver/riverpgxv5"
"github.com/riverqueue/river/rivershared/baseservice"
"github.com/riverqueue/river/rivershared/riversharedtest"
Expand Down Expand Up @@ -118,7 +121,7 @@ func TestWorker_Work(t *testing.T) {
})
tw := NewWorker(t, driver, config, worker)

// You can also use the WorkOpts method to pass in custom insert options:
// You can also pass in custom insert options:
require.NoError(t, tw.Work(context.Background(), t, testArgs{Value: "test3"}, &river.InsertOpts{
MaxAttempts: 420,
Metadata: []byte(`{"key": "value"}`),
Expand Down Expand Up @@ -163,24 +166,224 @@ func TestWorker_Work(t *testing.T) {
})
}

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

ctx := context.Background()

type testBundle struct {
client *river.Client[pgx.Tx]
driver *riverpgxv5.Driver
tx pgx.Tx
workFunc func(ctx context.Context, job *river.Job[testArgs]) error
}

setup := func(t *testing.T) (*Worker[testArgs, pgx.Tx], *testBundle) {
t.Helper()

var (
config = &river.Config{}
driver = riverpgxv5.New(nil)
)

client, err := river.NewClient(driver, config)
require.NoError(t, err)

bundle := &testBundle{
client: client,
driver: driver,
tx: riverinternaltest.TestTx(ctx, t),
workFunc: func(ctx context.Context, job *river.Job[testArgs]) error { return nil },
}

worker := river.WorkFunc(func(ctx context.Context, job *river.Job[testArgs]) error {
return bundle.workFunc(ctx, job)
})

return NewWorker(t, driver, config, worker), bundle
}

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

testWorker, bundle := setup(t)

bundle.workFunc = func(ctx context.Context, job *river.Job[testArgs]) error {
require.Equal(t, rivertype.JobStateRunning, job.State)
return nil
}

require.NoError(t, testWorker.WorkTx(ctx, t, bundle.tx, testArgs{}, nil))
})
}

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.

@bgentry This is a little unfortunate — you still won't be able to use JobCompleteTx for the Work/WorkTx variants because there's no way to inject the ID of the job which should be updated.

There might be ways to work around that, but none of them are good. e.g. We could have WorkTx insert a job record as running, but then you're producing data into the database in people's tests, which probably isn't good (i.e. you probably wouldn't be able to do this for Work because you don't know whether they're in a test transaction or not).

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.

This is a little unfortunate — you still won't be able to use JobCompleteTx for the Work/WorkTx variants because there's no way to inject the ID of the job which should be updated.

Can you explain a little more what you mean here? Even if it's a synthetic job, JobCompleteTx would still get called—it's just that it will error because the job doesn't actually exist, right? Maybe that was a mistake and we should just go all in w/ inserting real job rows.

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.

JobCompleteTx` would still get called—it's just that it will error because the job doesn't actually exist, right?

Yeah, exactly. There's no way for a user to put in a job row that'll let JobCompleteTx be called successfully.

Maybe that was a mistake and we should just go all in w/ inserting real job rows.

Yes, there's a good chance of that.


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

config := &river.Config{}
driver := riverpgxv5.New(nil)
ctx := context.Background()

type testBundle struct {
client *river.Client[pgx.Tx]
dbPool *pgxpool.Pool
driver *riverpgxv5.Driver
workFunc func(ctx context.Context, job *river.Job[testArgs]) error
}

setup := func(t *testing.T) (*Worker[testArgs, pgx.Tx], *testBundle) {
t.Helper()

var (
config = &river.Config{}
dbPool = riverinternaltest.TestDB(ctx, t)
driver = riverpgxv5.New(dbPool)
)

client, err := river.NewClient(driver, config)
require.NoError(t, err)

bundle := &testBundle{
client: client,
driver: driver,
dbPool: dbPool,
workFunc: func(ctx context.Context, job *river.Job[testArgs]) error { return nil },
}

worker := river.WorkFunc(func(ctx context.Context, job *river.Job[testArgs]) error {
return bundle.workFunc(ctx, job)
})

return NewWorker(t, driver, config, worker), bundle
}

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

testWorker, bundle := setup(t)

bundle.workFunc = func(ctx context.Context, job *river.Job[testArgs]) error {
require.Equal(t, []string{"worker123"}, job.JobRow.AttemptedBy)
require.Equal(t, rivertype.JobStateRunning, job.State)
return nil
}

now := time.Now()
require.NoError(t, testWorker.WorkJob(ctx, t, makeJobFromFactoryBuild(t, testArgs{}, &testfactory.JobOpts{
AttemptedAt: &now,
AttemptedBy: []string{"worker123"},
CreatedAt: &now,
EncodedArgs: []byte(`{"value": "test"}`),
Errors: nil,
})))
})

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

testWorker, bundle := setup(t)

worker := river.WorkFunc(func(ctx context.Context, job *river.Job[testArgs]) error {
require.Equal(t, []string{"worker123"}, job.JobRow.AttemptedBy)
return nil
args := testArgs{}
insertRes, err := bundle.client.Insert(ctx, args, nil)
require.NoError(t, err)

bundle.workFunc = func(ctx context.Context, job *river.Job[testArgs]) error {
tx, err := bundle.dbPool.Begin(ctx)
require.NoError(t, err)

updatedJob, err := bundle.driver.GetExecutor().JobGetByID(ctx, insertRes.Job.ID)
require.NoError(t, err)
require.Equal(t, rivertype.JobStateRunning, updatedJob.State)

_, err = river.JobCompleteTx[*riverpgxv5.Driver](ctx, tx, job)
require.NoError(t, err)

err = tx.Commit(ctx)
require.NoError(t, err)

return nil
}

require.NoError(t, testWorker.WorkJob(ctx, t, &river.Job[testArgs]{Args: args, JobRow: insertRes.Job}))

updatedJob, err := bundle.driver.GetExecutor().JobGetByID(ctx, insertRes.Job.ID)
require.NoError(t, err)
require.Equal(t, rivertype.JobStateCompleted, updatedJob.State)
})
}

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

ctx := context.Background()

type testBundle struct {
client *river.Client[pgx.Tx]
driver *riverpgxv5.Driver
tx pgx.Tx
workFunc func(ctx context.Context, job *river.Job[testArgs]) error
}

setup := func(t *testing.T) (*Worker[testArgs, pgx.Tx], *testBundle) {
t.Helper()

var (
config = &river.Config{}
driver = riverpgxv5.New(nil)
)

client, err := river.NewClient(driver, config)
require.NoError(t, err)

bundle := &testBundle{
client: client,
driver: driver,
tx: riverinternaltest.TestTx(ctx, t),
workFunc: func(ctx context.Context, job *river.Job[testArgs]) error { return nil },
}

worker := river.WorkFunc(func(ctx context.Context, job *river.Job[testArgs]) error {
return bundle.workFunc(ctx, job)
})

return NewWorker(t, driver, config, worker), bundle
}

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

testWorker, bundle := setup(t)

bundle.workFunc = func(ctx context.Context, job *river.Job[testArgs]) error {
require.Equal(t, rivertype.JobStateRunning, job.State)
return nil
}

require.NoError(t, testWorker.WorkJobTx(ctx, t, bundle.tx, makeJobFromFactoryBuild(t, testArgs{}, &testfactory.JobOpts{})))
})

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

testWorker, bundle := setup(t)

args := testArgs{}
insertRes, err := bundle.client.InsertTx(ctx, bundle.tx, args, nil)
require.NoError(t, err)

bundle.workFunc = func(ctx context.Context, job *river.Job[testArgs]) error {
updatedJob, err := bundle.driver.UnwrapExecutor(bundle.tx).JobGetByID(ctx, insertRes.Job.ID)
require.NoError(t, err)
require.Equal(t, rivertype.JobStateRunning, updatedJob.State)

_, err = river.JobCompleteTx[*riverpgxv5.Driver](ctx, bundle.tx, job)
require.NoError(t, err)

return nil
}

require.NoError(t, testWorker.WorkJobTx(ctx, t, bundle.tx, &river.Job[testArgs]{Args: args, JobRow: insertRes.Job}))

updatedJob, err := bundle.driver.UnwrapExecutor(bundle.tx).JobGetByID(ctx, insertRes.Job.ID)
require.NoError(t, err)
require.Equal(t, rivertype.JobStateCompleted, updatedJob.State)
})
tw := NewWorker(t, driver, config, worker)

now := time.Now()
require.NoError(t, tw.WorkJob(context.Background(), t, makeJobFromFactoryBuild(t, testArgs{Value: "test"}, &testfactory.JobOpts{
AttemptedAt: &now,
AttemptedBy: []string{"worker123"},
CreatedAt: &now,
EncodedArgs: []byte(`{"value": "test"}`),
Errors: nil,
})))
}