Skip to content

Commit e59702f

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 465b2a6 commit e59702f

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
@@ -24,6 +24,7 @@ import (
2424
"github.com/riverqueue/river/internal/middlewarelookup"
2525
"github.com/riverqueue/river/internal/notifier"
2626
"github.com/riverqueue/river/internal/notifylimiter"
27+
"github.com/riverqueue/river/internal/pluginconfig"
2728
"github.com/riverqueue/river/internal/rivercommon"
2829
"github.com/riverqueue/river/internal/rivermiddleware"
2930
"github.com/riverqueue/river/internal/workunit"
@@ -240,6 +241,9 @@ type Config struct {
240241
// work hook runs and the insertion hooks on either side of it are skipped.
241242
//
242243
// Jobs may have their own specific hooks by implementing JobArgsWithHooks.
244+
//
245+
// If a type in Hooks also implements rivertype.Middleware, it will be
246+
// installed as middleware too.
243247
Hooks []rivertype.Hook
244248

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

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

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

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

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

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