Skip to content

Commit c991820

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 751f4c4 commit c991820

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
@@ -23,6 +23,7 @@ import (
2323
"github.com/riverqueue/river/internal/middlewarelookup"
2424
"github.com/riverqueue/river/internal/notifier"
2525
"github.com/riverqueue/river/internal/notifylimiter"
26+
"github.com/riverqueue/river/internal/pluginconfig"
2627
"github.com/riverqueue/river/internal/rivercommon"
2728
"github.com/riverqueue/river/internal/rivermiddleware"
2829
"github.com/riverqueue/river/internal/workunit"
@@ -219,6 +220,9 @@ type Config struct {
219220
// work hook runs and the insertion hooks on either side of it are skipped.
220221
//
221222
// Jobs may have their own specific hooks by implementing JobArgsWithHooks.
223+
//
224+
// If a type in Hooks also implements rivertype.Middleware, it will be
225+
// installed as middleware too.
222226
Hooks []rivertype.Hook
223227

224228
// Logger is the structured logger to use for logging purposes. If none is
@@ -252,8 +256,25 @@ type Config struct {
252256
// middlewares will run one after another, and the work middleware between
253257
// them will not run. When a job is worked, the work middleware runs and the
254258
// insertion middlewares on either side of it are skipped.
259+
//
260+
// If a type in Middleware also implements rivertype.Hook, it will be
261+
// installed as a hook too.
255262
Middleware []rivertype.Middleware
256263

264+
// Plugins contains extensions installed globally as both hooks and
265+
// middleware.
266+
//
267+
// A plugin must implement both rivertype.Hook and rivertype.Middleware. It
268+
// may embed PluginDefaults, or embed both HookDefaults and
269+
// MiddlewareDefaults directly, then implement any operation-specific hook or
270+
// middleware interfaces it needs.
271+
//
272+
// Hooks and Middleware are still supported. If a type in Hooks also
273+
// implements middleware, or a type in Middleware also implements hooks, River
274+
// will install it on both sides automatically. The Plugins list exists as a
275+
// generic place for new extensions to be registered.
276+
Plugins []rivertype.Plugin
277+
257278
// PeriodicJobs are a set of periodic jobs to run at the specified intervals
258279
// in the client.
259280
PeriodicJobs []*PeriodicJob
@@ -476,6 +497,7 @@ func (c *Config) WithDefaults() *Config {
476497
MaxAttempts: cmp.Or(c.MaxAttempts, MaxAttemptsDefault),
477498
Middleware: c.Middleware,
478499
PeriodicJobs: c.PeriodicJobs,
500+
Plugins: c.Plugins,
479501
PollOnly: c.PollOnly,
480502
Queues: c.Queues,
481503
ReindexerIndexNames: reindexerIndexNames,
@@ -774,7 +796,11 @@ func NewClient[TTx any](driver riverdriver.Driver[TTx], config *Config) (*Client
774796
}
775797
}
776798

777-
for _, hook := range config.Hooks {
799+
configuredMiddleware := middlewareFromConfig(config)
800+
effectiveHooks := pluginconfig.Hooks(config.Hooks, configuredMiddleware, config.Plugins)
801+
effectiveMiddleware := pluginconfig.Middleware(config.Hooks, configuredMiddleware, config.Plugins)
802+
803+
for _, hook := range effectiveHooks {
778804
if withBaseService, ok := hook.(baseservice.WithBaseService); ok {
779805
baseservice.Init(archetype, withBaseService)
780806
}
@@ -788,7 +814,7 @@ func NewClient[TTx any](driver riverdriver.Driver[TTx], config *Config) (*Client
788814
config: config,
789815
driver: driver,
790816
hookLookupByJob: hooklookup.NewJobHookLookup(),
791-
hookLookupGlobal: hooklookup.NewHookLookup(config.Hooks),
817+
hookLookupGlobal: hooklookup.NewHookLookup(effectiveHooks),
792818
producersByQueueName: make(map[string]*producer),
793819
testSignals: clientTestSignals{},
794820
workCancel: func(cause error) {}, // replaced on start, but here in case StopAndCancel is called before start up
@@ -806,31 +832,12 @@ func NewClient[TTx any](driver riverdriver.Driver[TTx], config *Config) (*Client
806832
client.baseService.Name = "Client" // Have to correct the name because base service isn't embedded like it usually is
807833
client.insertNotifyLimiter = notifylimiter.NewLimiter(archetype, config.FetchCooldown)
808834

809-
// Validation ensures that config.JobInsertMiddleware/WorkerMiddleware or
810-
// the more abstract config.Middleware for middleware are set, but not both,
811-
// so in practice we never append all three of these to each other.
835+
// effectiveMiddleware contains configured middleware, hook/middleware
836+
// hybrids, and plugins. Default middleware stays first so user middleware
837+
// wraps inside River's internal defaults like before.
812838
{
813839
middleware := rivermiddleware.DefaultMiddleware()
814-
middleware = append(middleware, config.Middleware...)
815-
for _, jobInsertMiddleware := range config.JobInsertMiddleware {
816-
middleware = append(middleware, jobInsertMiddleware)
817-
}
818-
outerLoop:
819-
for _, workerMiddleware := range config.WorkerMiddleware {
820-
// Don't add the middleware if it also implements JobInsertMiddleware
821-
// and the instance has been added to config.JobInsertMiddleware. This
822-
// is a hedge to make sure we don't accidentally double add middleware
823-
// as we've converted over to the unified config.Middleware setting.
824-
if workerMiddlewareAsJobInsertMiddleware, ok := workerMiddleware.(rivertype.JobInsertMiddleware); ok {
825-
for _, jobInsertMiddleware := range config.JobInsertMiddleware {
826-
if workerMiddlewareAsJobInsertMiddleware == jobInsertMiddleware {
827-
continue outerLoop
828-
}
829-
}
830-
}
831-
832-
middleware = append(middleware, workerMiddleware)
833-
}
840+
middleware = append(middleware, effectiveMiddleware...)
834841

835842
for _, middleware := range middleware {
836843
if withBaseService, ok := middleware.(baseservice.WithBaseService); ok {
@@ -1040,6 +1047,35 @@ func NewClient[TTx any](driver riverdriver.Driver[TTx], config *Config) (*Client
10401047
return client, nil
10411048
}
10421049

1050+
func middlewareFromConfig(config *Config) []rivertype.Middleware {
1051+
middleware := make([]rivertype.Middleware, 0,
1052+
len(config.Middleware)+len(config.JobInsertMiddleware)+len(config.WorkerMiddleware))
1053+
middleware = append(middleware, config.Middleware...)
1054+
1055+
for _, jobInsertMiddleware := range config.JobInsertMiddleware {
1056+
middleware = append(middleware, jobInsertMiddleware)
1057+
}
1058+
1059+
outerLoop:
1060+
for _, workerMiddleware := range config.WorkerMiddleware {
1061+
// Don't add the middleware if it also implements JobInsertMiddleware
1062+
// and the instance has been added to config.JobInsertMiddleware. This
1063+
// is a hedge to make sure we don't accidentally double add middleware
1064+
// as we've converted over to the unified config.Middleware setting.
1065+
if workerMiddlewareAsJobInsertMiddleware, ok := workerMiddleware.(rivertype.JobInsertMiddleware); ok {
1066+
for _, jobInsertMiddleware := range config.JobInsertMiddleware {
1067+
if workerMiddlewareAsJobInsertMiddleware == jobInsertMiddleware {
1068+
continue outerLoop
1069+
}
1070+
}
1071+
}
1072+
1073+
middleware = append(middleware, workerMiddleware)
1074+
}
1075+
1076+
return middleware
1077+
}
1078+
10431079
// Start starts the client's job fetching and working loops. Once this is called,
10441080
// the client will run in a background goroutine until stopped. All jobs are
10451081
// 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" }
@@ -8195,6 +8266,121 @@ func Test_NewClient_Overrides(t *testing.T) {
81958266
require.Len(t, client.config.WorkerMiddleware, 1)
81968267
}
81978268

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

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)