-
Notifications
You must be signed in to change notification settings - Fork 167
Let test workers call JobCompleteTx + test work transaction variants
#765
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,6 +3,7 @@ package rivertest | |||||||||
| import ( | ||||||||||
| "context" | ||||||||||
| "encoding/json" | ||||||||||
| "errors" | ||||||||||
| "sync/atomic" | ||||||||||
| "testing" | ||||||||||
| "time" | ||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? river/riverdriver/riverpgxv5/internal/dbsqlc/river_job.sql Lines 151 to 154 in 0a8ee30
So we should consider both setting those when updating the job, and also making sure they're set on the actual job struct.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
|
@@ -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"}`), | ||
|
|
@@ -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)) | ||
| }) | ||
| } | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There might be ways to work around that, but none of them are good. e.g. We could have
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Can you explain a little more what you mean here? Even if it's a synthetic job,
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yeah, exactly. There's no way for a user to put in a job row that'll let
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, | ||
| }))) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ugh, lint auto correction.