Skip to content

Commit 6d60ff7

Browse files
committed
Cross-compatible hook/middleware + "plugins" which are allowed to be hook and middleware
This one's largely aimed at extending a few parts of `otelriver` to be able to emit some additional useful metrics like time that it takes to lock jobs, number of jobs locked per batch, or any other arbitrary metrics we want to emit down the road. I previously had something similar back in #1203, but here we extract an isolated piece of it. This change does two things: * If either a hook sent to `Config.Hooks` implements a middleware or a middleware sent to `Config.Middleware` implements a hook, activate its alternate side as well. * Establish a new `Config.Plugins` that acts as a more generalized place where a hook/middleware can go. We define a plugin as this type: type Plugin interface { Hook Middleware } The reason for the first point is better backward compatibility. Notably, if I add a hook to `otelriver.Middleware`, I want it to be able to still work even if the user doesn't explicitly movie it from `Config.Middleware` to `Config.Plugins`.
1 parent 4aa884e commit 6d60ff7

7 files changed

Lines changed: 336 additions & 29 deletions

File tree

client.go

Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/riverqueue/river/internal/middlewarelookup"
2626
"github.com/riverqueue/river/internal/notifier"
2727
"github.com/riverqueue/river/internal/notifylimiter"
28+
"github.com/riverqueue/river/internal/pluginconfig"
2829
"github.com/riverqueue/river/internal/rivercommon"
2930
"github.com/riverqueue/river/internal/rivermiddleware"
3031
"github.com/riverqueue/river/internal/workunit"
@@ -241,6 +242,9 @@ type Config struct {
241242
// work hook runs and the insertion hooks on either side of it are skipped.
242243
//
243244
// Jobs may have their own specific hooks by implementing JobArgsWithHooks.
245+
//
246+
// If a type in Hooks also implements rivertype.Middleware, it will be
247+
// installed as middleware too.
244248
Hooks []rivertype.Hook
245249

246250
// Logger is the structured logger to use for logging purposes. If none is
@@ -274,8 +278,25 @@ type Config struct {
274278
// middlewares will run one after another, and the work middleware between
275279
// them will not run. When a job is worked, the work middleware runs and the
276280
// insertion middlewares on either side of it are skipped.
281+
//
282+
// If a type in Middleware also implements rivertype.Hook, it will be
283+
// installed as a hook too.
277284
Middleware []rivertype.Middleware
278285

286+
// Plugins contains extensions installed globally as both hooks and
287+
// middleware.
288+
//
289+
// A plugin must implement both rivertype.Hook and rivertype.Middleware. It
290+
// may embed PluginDefaults, or embed both HookDefaults and
291+
// MiddlewareDefaults directly, then implement any operation-specific hook or
292+
// middleware interfaces it needs.
293+
//
294+
// Hooks and Middleware are still supported. If a type in Hooks also
295+
// implements middleware, or a type in Middleware also implements hooks, River
296+
// will install it on both sides automatically. The Plugins list exists as a
297+
// generic place for new extensions to be registered.
298+
Plugins []rivertype.Plugin
299+
279300
// PeriodicJobs are a set of periodic jobs to run at the specified intervals
280301
// in the client.
281302
PeriodicJobs []*PeriodicJob
@@ -500,6 +521,7 @@ func (c *Config) WithDefaults() *Config {
500521
MaxAttempts: cmp.Or(c.MaxAttempts, MaxAttemptsDefault),
501522
Middleware: c.Middleware,
502523
PeriodicJobs: c.PeriodicJobs,
524+
Plugins: c.Plugins,
503525
PollOnly: c.PollOnly,
504526
Queues: c.Queues,
505527
ReindexerIndexNames: reindexerIndexNames,
@@ -802,7 +824,11 @@ func NewClient[TTx any](driver riverdriver.Driver[TTx], config *Config) (*Client
802824
}
803825
}
804826

805-
for _, hook := range config.Hooks {
827+
configuredMiddleware := middlewareFromConfig(config)
828+
effectiveHooks := pluginconfig.Hooks(config.Hooks, configuredMiddleware, config.Plugins)
829+
effectiveMiddleware := pluginconfig.Middleware(config.Hooks, configuredMiddleware, config.Plugins)
830+
831+
for _, hook := range effectiveHooks {
806832
if withBaseService, ok := hook.(baseservice.WithBaseService); ok {
807833
baseservice.Init(archetype, withBaseService)
808834
}
@@ -816,7 +842,7 @@ func NewClient[TTx any](driver riverdriver.Driver[TTx], config *Config) (*Client
816842
config: config,
817843
driver: driver,
818844
hookLookupByJob: hooklookup.NewJobHookLookup(),
819-
hookLookupGlobal: hooklookup.NewHookLookup(config.Hooks),
845+
hookLookupGlobal: hooklookup.NewHookLookup(effectiveHooks),
820846
producersByQueueName: make(map[string]*producer),
821847
testSignals: clientTestSignals{},
822848
workCancel: func(cause error) {}, // replaced on start, but here in case StopAndCancel is called before start up
@@ -834,31 +860,12 @@ func NewClient[TTx any](driver riverdriver.Driver[TTx], config *Config) (*Client
834860
client.baseService.Name = "Client" // Have to correct the name because base service isn't embedded like it usually is
835861
client.insertNotifyLimiter = notifylimiter.NewLimiter(archetype, config.FetchCooldown)
836862

837-
// Validation ensures that config.JobInsertMiddleware/WorkerMiddleware or
838-
// the more abstract config.Middleware for middleware are set, but not both,
839-
// so in practice we never append all three of these to each other.
863+
// effectiveMiddleware contains configured middleware, hook/middleware
864+
// hybrids, and plugins. Default middleware stays first so user middleware
865+
// wraps inside River's internal defaults like before.
840866
{
841867
middleware := rivermiddleware.DefaultMiddleware()
842-
middleware = append(middleware, config.Middleware...)
843-
for _, jobInsertMiddleware := range config.JobInsertMiddleware {
844-
middleware = append(middleware, jobInsertMiddleware)
845-
}
846-
outerLoop:
847-
for _, workerMiddleware := range config.WorkerMiddleware {
848-
// Don't add the middleware if it also implements JobInsertMiddleware
849-
// and the instance has been added to config.JobInsertMiddleware. This
850-
// is a hedge to make sure we don't accidentally double add middleware
851-
// as we've converted over to the unified config.Middleware setting.
852-
if workerMiddlewareAsJobInsertMiddleware, ok := workerMiddleware.(rivertype.JobInsertMiddleware); ok {
853-
for _, jobInsertMiddleware := range config.JobInsertMiddleware {
854-
if workerMiddlewareAsJobInsertMiddleware == jobInsertMiddleware {
855-
continue outerLoop
856-
}
857-
}
858-
}
859-
860-
middleware = append(middleware, workerMiddleware)
861-
}
868+
middleware = append(middleware, effectiveMiddleware...)
862869

863870
for _, middleware := range middleware {
864871
if withBaseService, ok := middleware.(baseservice.WithBaseService); ok {
@@ -1068,6 +1075,35 @@ func NewClient[TTx any](driver riverdriver.Driver[TTx], config *Config) (*Client
10681075
return client, nil
10691076
}
10701077

1078+
func middlewareFromConfig(config *Config) []rivertype.Middleware {
1079+
middleware := make([]rivertype.Middleware, 0,
1080+
len(config.Middleware)+len(config.JobInsertMiddleware)+len(config.WorkerMiddleware))
1081+
middleware = append(middleware, config.Middleware...)
1082+
1083+
for _, jobInsertMiddleware := range config.JobInsertMiddleware {
1084+
middleware = append(middleware, jobInsertMiddleware)
1085+
}
1086+
1087+
outerLoop:
1088+
for _, workerMiddleware := range config.WorkerMiddleware {
1089+
// Don't add the middleware if it also implements JobInsertMiddleware
1090+
// and the instance has been added to config.JobInsertMiddleware. This
1091+
// is a hedge to make sure we don't accidentally double add middleware
1092+
// as we've converted over to the unified config.Middleware setting.
1093+
if workerMiddlewareAsJobInsertMiddleware, ok := workerMiddleware.(rivertype.JobInsertMiddleware); ok {
1094+
for _, jobInsertMiddleware := range config.JobInsertMiddleware {
1095+
if workerMiddlewareAsJobInsertMiddleware == jobInsertMiddleware {
1096+
continue outerLoop
1097+
}
1098+
}
1099+
}
1100+
1101+
middleware = append(middleware, workerMiddleware)
1102+
}
1103+
1104+
return middleware
1105+
}
1106+
10711107
// Start starts the client's job fetching and working loops. Once this is called,
10721108
// the client will run in a background goroutine until stopped. All jobs are
10731109
// run with a context inheriting from the provided context, but with a timeout

client_test.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,77 @@ type noOpWorker struct {
7070

7171
func (w *noOpWorker) Work(ctx context.Context, job *Job[noOpArgs]) error { return nil }
7272

73+
var (
74+
_ rivertype.HookInsertBegin = &hookMiddlewareEmbeddedDefaultsPlugin{}
75+
_ rivertype.JobInsertMiddleware = &hookMiddlewareEmbeddedDefaultsPlugin{}
76+
_ rivertype.Plugin = &hookMiddlewareEmbeddedDefaultsPlugin{}
77+
78+
_ rivertype.HookInsertBegin = &hookMiddlewarePlugin{}
79+
_ rivertype.JobInsertMiddleware = &hookMiddlewarePlugin{}
80+
_ rivertype.Plugin = &hookMiddlewarePlugin{}
81+
82+
_ rivertype.HookInsertBegin = hookMiddlewareValuePlugin{}
83+
_ rivertype.JobInsertMiddleware = hookMiddlewareValuePlugin{}
84+
)
85+
86+
type hookMiddlewareEmbeddedDefaultsPlugin struct {
87+
HookDefaults
88+
MiddlewareDefaults
89+
90+
insertBeginCount int
91+
insertManyCount int
92+
}
93+
94+
func (p *hookMiddlewareEmbeddedDefaultsPlugin) InsertBegin(ctx context.Context, params *rivertype.JobInsertParams) error {
95+
p.insertBeginCount++
96+
return nil
97+
}
98+
99+
func (p *hookMiddlewareEmbeddedDefaultsPlugin) InsertMany(ctx context.Context, manyParams []*rivertype.JobInsertParams, doInner func(context.Context) ([]*rivertype.JobInsertResult, error)) ([]*rivertype.JobInsertResult, error) {
100+
p.insertManyCount++
101+
return doInner(ctx)
102+
}
103+
104+
type hookMiddlewarePlugin struct {
105+
PluginDefaults
106+
107+
insertBeginCount int
108+
insertManyCount int
109+
}
110+
111+
func (p *hookMiddlewarePlugin) InsertBegin(ctx context.Context, params *rivertype.JobInsertParams) error {
112+
p.insertBeginCount++
113+
return nil
114+
}
115+
116+
func (p *hookMiddlewarePlugin) InsertMany(ctx context.Context, manyParams []*rivertype.JobInsertParams, doInner func(context.Context) ([]*rivertype.JobInsertResult, error)) ([]*rivertype.JobInsertResult, error) {
117+
p.insertManyCount++
118+
return doInner(ctx)
119+
}
120+
121+
type hookMiddlewareValuePlugin struct {
122+
counts *hookMiddlewareValuePluginCounts
123+
}
124+
125+
func (p hookMiddlewareValuePlugin) InsertBegin(ctx context.Context, params *rivertype.JobInsertParams) error {
126+
p.counts.insertBeginCount++
127+
return nil
128+
}
129+
130+
func (p hookMiddlewareValuePlugin) InsertMany(ctx context.Context, manyParams []*rivertype.JobInsertParams, doInner func(context.Context) ([]*rivertype.JobInsertResult, error)) ([]*rivertype.JobInsertResult, error) {
131+
p.counts.insertManyCount++
132+
return doInner(ctx)
133+
}
134+
135+
func (p hookMiddlewareValuePlugin) IsHook() bool { return true }
136+
137+
func (p hookMiddlewareValuePlugin) IsMiddleware() bool { return true }
138+
139+
type hookMiddlewareValuePluginCounts struct {
140+
insertBeginCount int
141+
insertManyCount int
142+
}
143+
73144
type periodicJobArgs struct{}
74145

75146
func (periodicJobArgs) Kind() string { return "periodic_job" }
@@ -8204,6 +8275,121 @@ func Test_NewClient_Overrides(t *testing.T) {
82048275
require.Len(t, client.config.WorkerMiddleware, 1)
82058276
}
82068277

8278+
func Test_NewClient_PluginsAndHybrids(t *testing.T) {
8279+
t.Parallel()
8280+
8281+
ctx := context.Background()
8282+
8283+
type testBundle struct {
8284+
config *Config
8285+
dbPool *pgxpool.Pool
8286+
}
8287+
8288+
setup := func(t *testing.T) *testBundle {
8289+
t.Helper()
8290+
8291+
dbPool := riversharedtest.DBPool(ctx, t)
8292+
driver := riverpgxv5.New(dbPool)
8293+
schema := riverdbtest.TestSchema(ctx, t, driver, nil)
8294+
8295+
return &testBundle{
8296+
config: newTestConfig(t, schema),
8297+
dbPool: dbPool,
8298+
}
8299+
}
8300+
8301+
insertAndRequireCounts := func(t *testing.T, bundle *testBundle, plugin *hookMiddlewarePlugin, expectedCount int) {
8302+
t.Helper()
8303+
8304+
client := newTestClient(t, bundle.dbPool, bundle.config)
8305+
8306+
_, err := client.Insert(ctx, noOpArgs{}, nil)
8307+
require.NoError(t, err)
8308+
8309+
require.Equal(t, expectedCount, plugin.insertBeginCount)
8310+
require.Equal(t, expectedCount, plugin.insertManyCount)
8311+
}
8312+
8313+
t.Run("DuplicatesAcrossHooksMiddlewareAndPluginsRunMultipleTimes", func(t *testing.T) {
8314+
t.Parallel()
8315+
8316+
bundle := setup(t)
8317+
plugin := &hookMiddlewarePlugin{}
8318+
bundle.config.Hooks = []rivertype.Hook{plugin}
8319+
bundle.config.Middleware = []rivertype.Middleware{plugin}
8320+
bundle.config.Plugins = []rivertype.Plugin{plugin}
8321+
8322+
insertAndRequireCounts(t, bundle, plugin, 3)
8323+
})
8324+
8325+
t.Run("HookAlsoRegisteredAsMiddleware", func(t *testing.T) {
8326+
t.Parallel()
8327+
8328+
bundle := setup(t)
8329+
plugin := &hookMiddlewarePlugin{}
8330+
bundle.config.Hooks = []rivertype.Hook{plugin}
8331+
8332+
insertAndRequireCounts(t, bundle, plugin, 1)
8333+
})
8334+
8335+
t.Run("MiddlewareAlsoRegisteredAsHook", func(t *testing.T) {
8336+
t.Parallel()
8337+
8338+
bundle := setup(t)
8339+
plugin := &hookMiddlewarePlugin{}
8340+
bundle.config.Middleware = []rivertype.Middleware{plugin}
8341+
8342+
insertAndRequireCounts(t, bundle, plugin, 1)
8343+
})
8344+
8345+
t.Run("PluginRegisteredAsHookAndMiddleware", func(t *testing.T) {
8346+
t.Parallel()
8347+
8348+
bundle := setup(t)
8349+
plugin := &hookMiddlewarePlugin{}
8350+
bundle.config.Plugins = []rivertype.Plugin{plugin}
8351+
8352+
insertAndRequireCounts(t, bundle, plugin, 1)
8353+
})
8354+
8355+
t.Run("PluginRegisteredWithEmbeddedDefaults", func(t *testing.T) {
8356+
t.Parallel()
8357+
8358+
bundle := setup(t)
8359+
plugin := &hookMiddlewareEmbeddedDefaultsPlugin{}
8360+
bundle.config.Plugins = []rivertype.Plugin{plugin}
8361+
8362+
client := newTestClient(t, bundle.dbPool, bundle.config)
8363+
8364+
_, err := client.Insert(ctx, noOpArgs{}, nil)
8365+
require.NoError(t, err)
8366+
8367+
require.Equal(t, 1, plugin.insertBeginCount)
8368+
require.Equal(t, 1, plugin.insertManyCount)
8369+
})
8370+
8371+
t.Run("SeparateEqualValueInstancesRunSeparately", func(t *testing.T) {
8372+
t.Parallel()
8373+
8374+
bundle := setup(t)
8375+
counts := &hookMiddlewareValuePluginCounts{}
8376+
bundle.config.Hooks = []rivertype.Hook{
8377+
hookMiddlewareValuePlugin{counts: counts},
8378+
}
8379+
bundle.config.Middleware = []rivertype.Middleware{
8380+
hookMiddlewareValuePlugin{counts: counts},
8381+
}
8382+
8383+
client := newTestClient(t, bundle.dbPool, bundle.config)
8384+
8385+
_, err := client.Insert(ctx, noOpArgs{}, nil)
8386+
require.NoError(t, err)
8387+
8388+
require.Equal(t, 2, counts.insertBeginCount)
8389+
require.Equal(t, 2, counts.insertManyCount)
8390+
})
8391+
}
8392+
82078393
func Test_NewClient_ReindexerIndexNamesExplicitEmptyOverride(t *testing.T) {
82088394
t.Parallel()
82098395

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package pluginconfig
2+
3+
import "github.com/riverqueue/river/rivertype"
4+
5+
// Hooks returns the effective hook list from configured hooks, middleware, and
6+
// plugins. Explicit hooks are preserved first, followed by middleware that also
7+
// implement hooks, then plugins.
8+
func Hooks(hooks []rivertype.Hook, middleware []rivertype.Middleware, plugins []rivertype.Plugin) []rivertype.Hook {
9+
effectiveHooks := make([]rivertype.Hook, 0, len(hooks)+len(middleware)+len(plugins))
10+
11+
effectiveHooks = append(effectiveHooks, hooks...)
12+
13+
for _, middlewareItem := range middleware {
14+
hook, ok := middlewareItem.(rivertype.Hook)
15+
if !ok {
16+
continue
17+
}
18+
19+
effectiveHooks = append(effectiveHooks, hook)
20+
}
21+
22+
for _, plugin := range plugins {
23+
effectiveHooks = append(effectiveHooks, plugin)
24+
}
25+
26+
return effectiveHooks
27+
}
28+
29+
// Middleware returns the effective middleware list from configured hooks,
30+
// middleware, and plugins. Explicit middleware are preserved first, followed by
31+
// hooks that also implement middleware, then plugins.
32+
func Middleware(hooks []rivertype.Hook, middleware []rivertype.Middleware, plugins []rivertype.Plugin) []rivertype.Middleware {
33+
effectiveMiddleware := make([]rivertype.Middleware, 0, len(hooks)+len(middleware)+len(plugins))
34+
35+
effectiveMiddleware = append(effectiveMiddleware, middleware...)
36+
37+
for _, hook := range hooks {
38+
middlewareItem, ok := hook.(rivertype.Middleware)
39+
if !ok {
40+
continue
41+
}
42+
43+
effectiveMiddleware = append(effectiveMiddleware, middlewareItem)
44+
}
45+
46+
for _, plugin := range plugins {
47+
effectiveMiddleware = append(effectiveMiddleware, plugin)
48+
}
49+
50+
return effectiveMiddleware
51+
}

0 commit comments

Comments
 (0)