Skip to content
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

rivertest: implement a Worker type to simplify testing #753

Merged
merged 6 commits into from
Feb 14, 2025

Conversation

bgentry
Copy link
Contributor

@bgentry bgentry commented Feb 11, 2025

Since the early days of the project, we've talked about potentially building some APIs to make it easier to test River workers in a realistic execution environment. This is an attempt to dramatically simplify this process, partly derived from testing implementations I wrote for River Pro's API backend code.

The rivertest.Worker type makes it much simpler to test River workers with a realistic execution environment without needing to touch the database at all. The type leverages a river.Config to allocate an inner Client and to enable jobs to be easily created with the normal default values, along with full access to features like middleweare. The execution context also contains all the normal stuff that a real context in River might, such as the client and a timeout if configured.

Here's a simple example of how to use this:

worker := river.WorkFunc(func(ctx context.Context, job *river.Job[testArgs]) error {
	return nil
})
testWorker := rivertest.NewWorker(t, driver, config, worker)

// Now that the `rivertest.Worker` has been initialized, it can be used to run as many different jobs as you'd like:
require.NoError(t, testWorker.Work(context.Background(), t, testArgs{Value: "test"}))

// An opts variant allows customizing insert opts:
require.NoError(t, testWorker.Work(context.Background(), t, testArgs{Value: "test"}), &river.InsertOpts{Metadata: `{"some": "value"}`))

// Want to totally customize the input job to test it in any desired state? See `WorkJob` or `WorkJobOpts`.

This is missing a lot of test coverage and there are some open TODOs like extracting the insertParamsFromConfigArgsAndOptions from the main river package so rivertest can also use it, but this should hopefully illustrate what I think is a pretty smooth API for easier testing of workers than what it otherwise takes today.

Related issue: #605

Currently based on #754.

@bgentry bgentry requested a review from brandur February 11, 2025 02:43
@bgentry bgentry force-pushed the bg-rivertest-work-job branch from d47c2a3 to f3226b9 Compare February 11, 2025 04:15
Copy link
Contributor

@brandur brandur left a comment

Choose a reason for hiding this comment

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

Cool. Yep, idea looks good to me. Maybe finish it up and add some niceties like docs on the public-facing functions and maybe an example test to demonstrate usage.

return nil, err
}

// TODO: use insertParamsFromConfigArgsAndOptions instead of reimplementing it here?
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@brandur looked at this one a bit, and insertParamsFromConfigArgsAndOptions can't really be extracted from the main package because there are a number of dependencies on its own types, including interfaces whose methods return structs in that package. There's no way to avoid circular dependencies.

Options I see:

  1. Live with the ugliness of exporting this API even though it's not useful or intended for public usage
  2. Essentially reimplement its logic here to create realistic test jobs
  3. Instead use rivershared/testfactory for building test jobs (wrap testfactory.Job_Build to return a *Job[T] instead of *riverdriver.JobInsertFullParams.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Went for option (3) here

@bgentry bgentry force-pushed the bg-rivertest-work-job branch from f3226b9 to ae4ee95 Compare February 13, 2025 04:23
@bgentry bgentry requested a review from brandur February 13, 2025 04:25
@bgentry
Copy link
Contributor Author

bgentry commented Feb 13, 2025

@brandur this is still missing documentation and an example but I think the implementation is essentially there. Want to take a look?

@bgentry bgentry force-pushed the bg-rivertest-work-job branch from ae4ee95 to 1f0f526 Compare February 13, 2025 19:18
@bgentry bgentry marked this pull request as ready for review February 13, 2025 19:19
@bgentry bgentry force-pushed the bg-rivertest-work-job branch 2 times, most recently from d6f92fc to 52b9190 Compare February 13, 2025 19:23

// WorkOpts allocates a synthetic job with the provided arguments and insert
// options, then works it.
func (w *Worker[T, TTx]) WorkOpts(ctx context.Context, tb testing.TB, args T, opts river.InsertOpts) error {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I opted to make a separate helper method for working a job with custom insert opts because (1) I expect the vast majority of use cases won't need to care about this and will only care about worker + args, and (2) the much more common use case will benefit from not needing to specify , nil at the end of every call.

In doing so I also opted to make the opts arg just a plain struct instead of a pointer, because it should always be provided when calling the more specialized helper method.

Copy link
Contributor

Choose a reason for hiding this comment

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

I opted to make a separate helper method for working a job with custom insert opts because (1) I expect the vast majority of use cases won't need to care about this and will only care about worker + args, and (2) the much more common use case will benefit from not needing to specify , nil at the end of every call.

Works for me I suppose, but just a note that you could state that exact same rationale as to why Insert should've been broken into Insert + InsertOpts.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right and I had a similar thought. Consolidated to just Work (mirroring client Insert method) + WorkJob for a full job struct.

Comment on lines +34 to +37
// In all cases the underlying [river.Worker] will be called with the synthetic
// job. The execution environment has a realistic River context with access to
// all River features, including [river.ClientFromContext] and worker
// middleware.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I realized there's a gap in this statement: river.JobCompleteTx won't be able to succeed if the job isn't actually real / isn't in the database. I see two options for working around this:

  1. Tell users who run into this that they need to first insert a real job, and then use WorkJob to test working it.
  2. Embed a value in the work context to indicate to JobCompleteTx that it's running within a special test helper and it shouldn't worry about whether the job exists.

Copy link
Contributor

Choose a reason for hiding this comment

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

Discussed a bit on Slack. Option (2) seems to be the most plausible, although stubbing out real functionality that'd otherwise hit a DB or do so something else in the real world is always a little icky. Hopefully though this is a special case where river.JobCompleteTx is already tested so well that it's not as necessary to do it here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This turned out to be more complicated so we decided to leverage the context value to panic if JobCompleteTx is called within a test worker environment and when the job doesn't exist in the database.

@bgentry bgentry force-pushed the bg-rivertest-work-job branch from 52b9190 to 958c2c2 Compare February 13, 2025 19:30
Comment on lines 53 to 60
if len(rows) == 0 {
return nil, rivertype.ErrNotFound
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Uncovered this bug while thinking about how JobCompleteTx would function if called from the new test helpers. Will add it to the changelog.

@bgentry bgentry force-pushed the bg-rivertest-work-job branch 3 times, most recently from 8398517 to be5fdb6 Compare February 14, 2025 17:00
@bgentry bgentry changed the base branch from master to bg-expose-test-clock February 14, 2025 17:07
@bgentry bgentry force-pushed the bg-expose-test-clock branch from da869ef to 6ec16c2 Compare February 14, 2025 17:14
@bgentry bgentry force-pushed the bg-rivertest-work-job branch 2 times, most recently from 4bccc25 to 12f2aaf Compare February 14, 2025 20:27
Base automatically changed from bg-expose-test-clock to master February 14, 2025 23:14
Make this util more useful with variadic args, functioning more
similarly to `valutil.FirstNonZero` by returning the first non-empty
value or else `nil`.
@bgentry bgentry force-pushed the bg-rivertest-work-job branch from 12f2aaf to 46ec75e Compare February 14, 2025 23:37
The `rivertest.Worker` type makes it much simpler to test River workers
with a realistic execution environment without needing to touch the
database at all. The type leverages a `river.Config` to allocate an
inner `Client` and to enable jobs to be easily created with the normal
default values, along with full access to features like middleweare. The
execution context also contains all the normal stuff that a real context
in River might, such as the client and a timeout if configured.
@bgentry bgentry force-pushed the bg-rivertest-work-job branch from 46ec75e to 1a7e5ac Compare February 14, 2025 23:43
//
// When relying on features that require a database record (such as JobCompleteTx),
// the job must be inserted into the database first and then executed with
// WorkJob.
Copy link
Contributor

Choose a reason for hiding this comment

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

We can probably punt on this, but it'd probably make sense to have a test or something that'd make sure users have a sane way to do this.

JobCompleteTx will probably refuse to complete the job unless it's running, and there might not be that easy of a way to manipulate a job from available to running right now. They could Client.Insert, but then it'd get stuck there.

A couple possibilities:

  • We could provide a test helper for inserting jobs in any state similar to our internal factories.
  • We could have the test helpers here check to see if a job exists in the DB when running. If so, transition the job to running.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I just ran into that while writing the test for this! 😆 I think it would make sense as a fast follow so we can refine the API a little more.

This can't succeed if the record isn't in the database, so panic if this
situation occurs within a test worker environment.
@bgentry bgentry force-pushed the bg-rivertest-work-job branch from 1a7e5ac to 8b36c26 Compare February 14, 2025 23:47
@bgentry bgentry enabled auto-merge (squash) February 14, 2025 23:48
@bgentry bgentry merged commit 0132e53 into master Feb 14, 2025
10 checks passed
@bgentry bgentry deleted the bg-rivertest-work-job branch February 14, 2025 23:50
brandur added a commit that referenced this pull request Feb 15, 2025
Follows up #753 to make it possible to call `JobCompleteTx` inside test
worker implementations. The test worker tries to update an equivalent
job in the database's state to `running` so `JobCompleteTx` will be able
to find it when called (it only considers jobs in `running` state).

We also augment the API so that it's workable for users who want to use
test transactions in their tests (which is hopefully the vast majority
of users) by adding transaction variants `WorkTx` and `WorkJobTx`.
brandur added a commit that referenced this pull request Feb 15, 2025
Follows up #753 to make it possible to call `JobCompleteTx` inside test
worker implementations. The test worker tries to update an equivalent
job in the database's state to `running` so `JobCompleteTx` will be able
to find it when called (it only considers jobs in `running` state).

We also augment the API so that it's workable for users who want to use
test transactions in their tests (which is hopefully the vast majority
of users) by adding transaction variants `WorkTx` and `WorkJobTx`.
bgentry pushed a commit that referenced this pull request Feb 17, 2025
Follows up #753 to make it possible to call `JobCompleteTx` inside test
worker implementations. The test worker tries to update an equivalent
job in the database's state to `running` so `JobCompleteTx` will be able
to find it when called (it only considers jobs in `running` state).

We also augment the API so that it's workable for users who want to use
test transactions in their tests (which is hopefully the vast majority
of users) by adding transaction variants `WorkTx` and `WorkJobTx`.
bgentry pushed a commit that referenced this pull request Feb 17, 2025
Follows up #753 to make it possible to call `JobCompleteTx` inside test
worker implementations. The test worker tries to update an equivalent
job in the database's state to `running` so `JobCompleteTx` will be able
to find it when called (it only considers jobs in `running` state).

We also augment the API so that it's workable for users who want to use
test transactions in their tests (which is hopefully the vast majority
of users) by adding transaction variants `WorkTx` and `WorkJobTx`.
bgentry added a commit that referenced this pull request Feb 18, 2025
)

* allow setting created_at on insert for clock overrides

While the client code was attempting to set `created_at` when the clock
was stubbed, the driver code was not actually utilizing this at all.
This commit makes it possible to set the `created_at` (similar to
`scheduled_at`) using a mocked clock. It also removes the ability to
set `finalized_at`, which has no need to be set at insert (other than
for tests utilizing `JobInsertFull`).

* Let test workers call `JobCompleteTx` + test work transaction variants

Follows up #753 to make it possible to call `JobCompleteTx` inside test
worker implementations. The test worker tries to update an equivalent
job in the database's state to `running` so `JobCompleteTx` will be able
to find it when called (it only considers jobs in `running` state).

We also augment the API so that it's workable for users who want to use
test transactions in their tests (which is hopefully the vast majority
of users) by adding transaction variants `WorkTx` and `WorkJobTx`.

* always take tx in rivertest.Worker methods

* always insert jobs for testworker

* use real executor for rivertest worker, alter APIs

Leverage the actual job executor for the `rivertest.Worker` type so that
it closely matches real job executions. Reduce the exposed APIs so it
only has `Work` and `WorkJob`, both of which require a transaction (but
the latter works on existing jobs).

Rather than returning only an error, also return a `WorkResult` struct
that contains additional metadata. Populate it with the final updated
job and the event kind to indicate whether the job completed, errored,
snoozed, cancelled, etc.

---------

Co-authored-by: Brandur <brandur@brandur.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants