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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

⚠️ Version 0.19.0 has minor breaking changes for the `Worker.Middleware`, introduced fairly recently in 0.17.0. We tried not to make this change, but found the existing middleware interface insufficient to provide the necessary range of functionality we wanted, and this is a secondary middleware facility that won't be in use for many users, so it seemed worthwhile.

### Changed

- The `river.RecordOutput` function now returns an error if the output is too large. The output is limited to 32MB in size. [PR #782](https://github.com/riverqueue/river/pull/782).
- **Breaking change:** The `Worker` interface's `Middleware` function now takes a `JobRow` parameter instead of a generic `Job[T]`. This was necessary to expand the potential of what middleware can do: by letting the executor extract a middleware stack from a worker before a job is fully unmarshaled, the middleware can also participate in the unmarshaling process. [PR #783](https://github.com/riverqueue/river/pull/783).

## [0.18.0] - 2025-02-20

Expand Down
57 changes: 50 additions & 7 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -661,8 +661,8 @@ func Test_Client(t *testing.T) {
require.Equal(t, "called", ctx.Value(privateKey("middleware")))
return nil
},
middlewareFunc: func(job *Job[callbackArgs]) []rivertype.WorkerMiddleware {
require.Equal(t, "middleware_test", job.Args.Name, "JSON should be decoded before middleware is called")
middlewareFunc: func(job *rivertype.JobRow) []rivertype.WorkerMiddleware {
require.Equal(t, "callback", job.Kind)

return []rivertype.WorkerMiddleware{
&overridableJobMiddleware{
Expand Down Expand Up @@ -694,6 +694,49 @@ func Test_Client(t *testing.T) {
require.True(t, middlewareCalled)
})

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

_, bundle := setup(t)
middlewareCalled := false

worker := &workerWithMiddleware[callbackArgs]{
workFunc: func(ctx context.Context, job *Job[callbackArgs]) error {
require.Equal(t, "middleware name", job.Args.Name)
return nil
},
middlewareFunc: func(job *rivertype.JobRow) []rivertype.WorkerMiddleware {
return []rivertype.WorkerMiddleware{
&overridableJobMiddleware{
workFunc: func(ctx context.Context, job *rivertype.JobRow, doInner func(ctx context.Context) error) error {
middlewareCalled = true
require.Equal(t, `{"name": "inserted name"}`, string(job.EncodedArgs))
job.EncodedArgs = []byte(`{"name": "middleware name"}`)
return doInner(ctx)
},
},
}
},
}

AddWorker(bundle.config.Workers, worker)

driver := riverpgxv5.New(bundle.dbPool)
client, err := NewClient(driver, bundle.config)
require.NoError(t, err)

subscribeChan := subscribe(t, client)
startClient(ctx, t, client)

result, err := client.Insert(ctx, callbackArgs{Name: "inserted name"}, nil)
require.NoError(t, err)

event := riversharedtest.WaitOrTimeout(t, subscribeChan)
require.Equal(t, EventKindJobCompleted, event.Kind)
require.Equal(t, result.Job.ID, event.Job.ID)
require.True(t, middlewareCalled)
})

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

Expand Down Expand Up @@ -943,15 +986,15 @@ func Test_Client(t *testing.T) {
type workerWithMiddleware[T JobArgs] struct {
WorkerDefaults[T]
workFunc func(context.Context, *Job[T]) error
middlewareFunc func(*Job[T]) []rivertype.WorkerMiddleware
middlewareFunc func(*rivertype.JobRow) []rivertype.WorkerMiddleware
}

func (w *workerWithMiddleware[T]) Work(ctx context.Context, job *Job[T]) error {
return w.workFunc(ctx, job)
func (w *workerWithMiddleware[T]) Middleware(job *rivertype.JobRow) []rivertype.WorkerMiddleware {
return w.middlewareFunc(job)
}

func (w *workerWithMiddleware[T]) Middleware(job *Job[T]) []rivertype.WorkerMiddleware {
return w.middlewareFunc(job)
func (w *workerWithMiddleware[T]) Work(ctx context.Context, job *Job[T]) error {
return w.workFunc(ctx, job)
}

func Test_Client_Stop(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func waitForNJobs(subscribeChan <-chan *river.Event, numJobs int) {
}

case <-time.After(time.Until(deadline)):
panic(fmt.Sprintf("WaitOrTimeout timed out after waiting %s (received %d job(s), wanted %d)",
panic(fmt.Sprintf("waitForNJobs timed out after waiting %s (received %d job(s), wanted %d)",
timeout, len(events), numJobs))
}
}
Expand Down
21 changes: 12 additions & 9 deletions internal/execution/execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,22 @@ func MaybeApplyTimeout(ctx context.Context, timeout time.Duration) (context.Cont
// MiddlewareChain chains together the given middleware functions, returning a
// single function that applies them all in reverse order.
func MiddlewareChain(global, worker []rivertype.WorkerMiddleware, doInner Func, jobRow *rivertype.JobRow) Func {
// Quick return for no middleware, which will often be the case.
if len(global) < 1 && len(worker) < 1 {
return doInner
}

allMiddleware := make([]rivertype.WorkerMiddleware, 0, len(global)+len(worker))
allMiddleware = append(allMiddleware, global...)
allMiddleware = append(allMiddleware, worker...)

if len(allMiddleware) > 0 {
// Wrap middlewares in reverse order so the one defined first is wrapped
// as the outermost function and is first to receive the operation.
for i := len(allMiddleware) - 1; i >= 0; i-- {
middlewareItem := allMiddleware[i] // capture the current middleware item
previousDoInner := doInner // capture the current doInner function
doInner = func(ctx context.Context) error {
return middlewareItem.Work(ctx, jobRow, previousDoInner)
}
// Wrap middlewares in reverse order so the one defined first is wrapped
// as the outermost function and is first to receive the operation.
for i := len(allMiddleware) - 1; i >= 0; i-- {
middlewareItem := allMiddleware[i] // capture the current middleware item
previousDoInner := doInner // capture the current doInner function
doInner = func(ctx context.Context) error {
return middlewareItem.Work(ctx, jobRow, previousDoInner)
}
}

Expand Down
21 changes: 13 additions & 8 deletions internal/jobexecutor/job_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,21 @@ func (e *JobExecutor) execute(ctx context.Context) (res *jobExecutorResult) {
return &jobExecutorResult{Err: &rivertype.UnknownJobKindError{Kind: e.JobRow.Kind}, MetadataUpdates: metadataUpdates}
}

if err := e.WorkUnit.UnmarshalJob(); err != nil {
return &jobExecutorResult{Err: err, MetadataUpdates: metadataUpdates}
}
doInner := execution.Func(func(ctx context.Context) error {
if err := e.WorkUnit.UnmarshalJob(); err != nil {
return err
}

jobTimeout := valutil.FirstNonZero(e.WorkUnit.Timeout(), e.ClientJobTimeout)
ctx, cancel := execution.MaybeApplyTimeout(ctx, jobTimeout)
defer cancel()

return e.WorkUnit.Work(ctx)
})

doInner := execution.MiddlewareChain(e.GlobalMiddleware, e.WorkUnit.Middleware(), e.WorkUnit.Work, e.JobRow)
jobTimeout := valutil.FirstNonZero(e.WorkUnit.Timeout(), e.ClientJobTimeout)
ctx, cancel := execution.MaybeApplyTimeout(ctx, jobTimeout)
defer cancel()
Comment on lines -192 to -193

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.

A notable and maybe unintentional change here: the context passed to middleware is no longer being cancelled at any point, because you're only canceling the innermost context given to the worker's Work(). Maybe this should be resolved by adding a context.WithCancel() and defer cancel() up at the top of execute() so ensure that the context is 100% always canceled for all layers once execution is done?

@brandur brandur Feb 24, 2025

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.

Hmm, okay, I think this might be a big difference in how you and I understand contexts/cancellations, but I still don't feel great about littering random context.WithCancel()s all over the place where the cancel() is never intended for use except on defer return.

Think about it this way: the only time cancel() is called is if we've successfully returned from middleware functions. If any middleware gets stuck somewhere waiting, the cancel will never be called anyway because it's only called on defer. So it has no effect in the success case, and no effect in the failure case either.

So the only thing we're protecting against by adding more cancels is cases where a middleware was able to return successfully, but then improperly spawned off some extraneous goroutines but which will properly respond to a cancelled context (meaning they'd have to be selecting on ctx.Done(), which is a really easy thing to forget).

IMO, it shouldn't really be a framework's job to cancel that kind of stuff. I'd even go so far as to say that it'd be better if it didn't, because that'd potentially let the problem be found more easily and fixed, whereas context cancellations from River might paper over the problem for a long time in this one place, but letting it maybe still occur somewhere else.

I re-read the docs for WithCancel in case I've been missing something big all these years, but I can't find anything in there suggesting use of WithCancel in places where cancel() is not intended to be used (e.g. see the example there, which uses cancel to stop an associated goroutine when its work is no longer needed).

I looked into putting in some sort global context cancellation of another kind of middlewares, but I couldn't find any easy resolution for this one right now because no cancellation should be applied to doInner, and doInner is intrinsically linked to the rest of the middleware invocation.

IMO again, it might be better not to have one anyway, and instead encourage each middleware to select appropriate timeouts if they need it. i.e. Maybe it makes sense for a concurrency limiter middleware to have one, but an encryption middleware should have no context timeout at all (one will be ignored it it's set anyway).

Thoughts on all that? Okay leaving this out for the time being and continuing to refine this as we go?

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.

I guess I see it as being conceptually the same as the context on an incoming http.Request. From https://pkg.go.dev/net/http#Request.Context :

For incoming server requests, the context is canceled when the client's connection closes, the request is canceled (with HTTP/2), or when the ServeHTTP method returns.

The idea being that as soon as user-controlled code is completed, the context is canceled to prevent any potential leaked resources. I see the same pattern is followed for gRPC.

To me it makes sense given all the ways in which a context is scoped to a specific job being executed, and it feels like a low cost nicety that might save users some pain vs having to remember to do it themselves when spawning concurrent goroutines in a job. But it’s not a hill I’ll die on, so your call.

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.

I guess I see it as being conceptually the same as the context on an incoming http.Request. From https://pkg.go.dev/net/http#Request.Context :

K, I view this as quite a different situation because in the case of an HTTP handler, something might be happening out of band (i.e. the original request that the handler is trying to serve's connection is closed) which makes work being done by a handler superfluous, and therefore it's a very useful feature for the context to be cancelled, because you're preventing additional work that no longer needs to be done.

In this case, the cancel() would only happen on return, so nothing would ever happen out-of-band. Unless I'm missing something here, there's no circumstances under which this would save additional work (as mentioned above, it would necessitate the middleware stack to return before cancel() could ever be invoked).

Going to merge as is for now, but we've got to retool how timeouts work on middleware stacks anyway, so let's get into that next.

executeFunc := execution.MiddlewareChain(e.GlobalMiddleware, e.WorkUnit.Middleware(), doInner, e.JobRow)

return &jobExecutorResult{Err: doInner(ctx), MetadataUpdates: metadataUpdates}
return &jobExecutorResult{Err: executeFunc(ctx), MetadataUpdates: metadataUpdates}
}

func (e *JobExecutor) invokeErrorHandler(ctx context.Context, res *jobExecutorResult) bool {
Expand Down
2 changes: 1 addition & 1 deletion rivertest/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ func (w *wrapperWorkUnit[T]) Timeout() time.Duration { return w.worker.T
func (w *wrapperWorkUnit[T]) Work(ctx context.Context) error { return w.worker.Work(ctx, w.job) }

func (w *wrapperWorkUnit[T]) Middleware() []rivertype.WorkerMiddleware {
return w.worker.Middleware(w.job)
return w.worker.Middleware(w.jobRow)
}

func (w *wrapperWorkUnit[T]) UnmarshalJob() error {
Expand Down
2 changes: 1 addition & 1 deletion work_unit_wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func (w *wrapperWorkUnit[T]) Timeout() time.Duration { return w.worker.T
func (w *wrapperWorkUnit[T]) Work(ctx context.Context) error { return w.worker.Work(ctx, w.job) }

func (w *wrapperWorkUnit[T]) Middleware() []rivertype.WorkerMiddleware {
return w.worker.Middleware(w.job)
return w.worker.Middleware(w.jobRow)
}

func (w *wrapperWorkUnit[T]) UnmarshalJob() error {
Expand Down
4 changes: 2 additions & 2 deletions worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import (
// with the client using the AddWorker function.
type Worker[T JobArgs] interface {
// Middleware returns the type-specific middleware for this job.
Middleware(job *Job[T]) []rivertype.WorkerMiddleware
Middleware(job *rivertype.JobRow) []rivertype.WorkerMiddleware

// NextRetry calculates when the next retry for a failed job should take
// place given when it was last attempted and its number of attempts, or any
Expand Down Expand Up @@ -74,7 +74,7 @@ type Worker[T JobArgs] interface {
// struct to make it fulfill the Worker interface with default values.
type WorkerDefaults[T JobArgs] struct{}

func (w WorkerDefaults[T]) Middleware(*Job[T]) []rivertype.WorkerMiddleware { return nil }
func (w WorkerDefaults[T]) Middleware(*rivertype.JobRow) []rivertype.WorkerMiddleware { return nil }

// NextRetry returns an empty time.Time{} to avoid setting any job or
// Worker-specific overrides on the next retry time. This means that the
Expand Down