Skip to content

Commit c318468

Browse files
committed
Add new HookMetricEmit that can emit arbitrary metrics
I was thinking about observability in River the other day, and how if one were trying to track River performance degradation due to dead tuples, how you might do so. Surprisingly, I don't think it's really possible under our current framework -- the best proxy for it is time to lock jobs, a metric which we don't provide any way of extracting out of River. Here, add a new hook called `HookMetricEmit`. The idea behind it is that it may receive an arbitrary metric that River starts to emit, and handle it by passing it onto a metrics system of choice, like OTEL, DataDog, or whatever. Within `HookMetricEmit`, add two specific metrics to get us started: * `JobGetAvailableDurationMetric`: Time to lock available jobs. This will start to show significant degradation in case of dead tuples. * `JobGetAvailableCountMetric`: Number of jobs locked while getting available. This is somewhat useful in seeing how well batch locking is working out in a program (i.e. are we locking one job at a time or 100?).
1 parent 5f1913a commit c318468

8 files changed

Lines changed: 339 additions & 16 deletions

File tree

client.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,11 @@ type Config struct {
226226
// lifecycle (see rivertype.Hook), installed globally.
227227
//
228228
// The effect of hooks in this list will depend on the specific hook
229-
// interfaces they implement, so for example implementing
230-
// rivertype.HookInsertBegin will cause the hook to be invoked before a job
231-
// is inserted, or implementing rivertype.HookWorkBegin will cause it to be
232-
// invoked before a job is worked. Hook structs may implement multiple hook
233-
// interfaces.
229+
// interfaces they implement. rivertype.HookInsertBegin will cause the hook
230+
// to be invoked before a job is inserted. rivertype.HookMetricEmit will
231+
// cause the hook to be invoked when River emits a metric. Implementing
232+
// rivertype.HookWorkBegin will cause it to be invoked before a job is
233+
// worked. Hook structs may implement multiple hook interfaces.
234234
//
235235
// Order in this list is significant. A hook that appears first will be
236236
// entered before a hook that appears later. For any particular phase, order

hook_defaults_funcs.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ func (f HookInsertBeginFunc) IsHook() bool { return true }
2727

2828
func (f HookInsertBeginFunc) IsPlugin() bool { return true }
2929

30+
// HookMetricEmitFunc is a convenience helper for implementing
31+
// rivertype.HookMetricEmit using a simple function instead of a struct.
32+
type HookMetricEmitFunc func(ctx context.Context, params *rivertype.HookMetricEmitParams)
33+
34+
func (f HookMetricEmitFunc) IsHook() bool { return true }
35+
36+
func (f HookMetricEmitFunc) IsPlugin() bool { return true }
37+
38+
func (f HookMetricEmitFunc) MetricEmit(ctx context.Context, params *rivertype.HookMetricEmitParams) {
39+
f(ctx, params)
40+
}
41+
3042
// HookPeriodicJobsStartFunc is a convenience helper for implementing
3143
// rivertype.HookPeriodicJobsStart using a simple function instead of a struct.
3244
type HookPeriodicJobsStartFunc func(ctx context.Context, params *rivertype.HookPeriodicJobsStartParams) error

hook_defaults_funcs_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ var (
1212
_ rivertype.HookInsertBegin = HookInsertBeginFunc(func(ctx context.Context, params *rivertype.JobInsertParams) error { return nil })
1313
_ rivertype.Plugin = HookInsertBeginFunc(func(ctx context.Context, params *rivertype.JobInsertParams) error { return nil })
1414

15+
_ rivertype.Hook = HookMetricEmitFunc(func(ctx context.Context, params *rivertype.HookMetricEmitParams) {})
16+
_ rivertype.HookMetricEmit = HookMetricEmitFunc(func(ctx context.Context, params *rivertype.HookMetricEmitParams) {})
17+
_ rivertype.Plugin = HookMetricEmitFunc(func(ctx context.Context, params *rivertype.HookMetricEmitParams) {})
18+
1519
_ rivertype.Hook = HookPeriodicJobsStartFunc(func(ctx context.Context, params *rivertype.HookPeriodicJobsStartParams) error { return nil })
1620
_ rivertype.HookPeriodicJobsStart = HookPeriodicJobsStartFunc(func(ctx context.Context, params *rivertype.HookPeriodicJobsStartParams) error { return nil })
1721
_ rivertype.Plugin = HookPeriodicJobsStartFunc(func(ctx context.Context, params *rivertype.HookPeriodicJobsStartParams) error { return nil })

internal/pluginlookup/plugin_lookup.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type PluginKind string
1515

1616
const (
1717
PluginKindHookInsertBegin PluginKind = "hook_insert_begin"
18+
PluginKindHookMetricEmit PluginKind = "hook_metric_emit"
1819
PluginKindHookPeriodicJobsStart PluginKind = "hook_periodic_jobs_start"
1920
PluginKindHookWorkBegin PluginKind = "hook_work_begin"
2021
PluginKindHookWorkEnd PluginKind = "hook_work_end"
@@ -85,6 +86,9 @@ func NewPluginLookup(plugins []any) PluginLookupInterface {
8586
if _, ok := plugin.(rivertype.HookInsertBegin); ok {
8687
pluginsByKind[PluginKindHookInsertBegin] = append(pluginsByKind[PluginKindHookInsertBegin], plugin)
8788
}
89+
if _, ok := plugin.(rivertype.HookMetricEmit); ok {
90+
pluginsByKind[PluginKindHookMetricEmit] = append(pluginsByKind[PluginKindHookMetricEmit], plugin)
91+
}
8892
if _, ok := plugin.(rivertype.HookPeriodicJobsStart); ok {
8993
pluginsByKind[PluginKindHookPeriodicJobsStart] = append(pluginsByKind[PluginKindHookPeriodicJobsStart], plugin)
9094
}

internal/pluginlookup/plugin_lookup_test.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ func TestEmptyPluginLookup(t *testing.T) {
3030
pluginLookup, _ := setup(t)
3131

3232
require.Nil(t, pluginLookup.ByKind(PluginKindHookInsertBegin))
33+
require.Nil(t, pluginLookup.ByKind(PluginKindHookMetricEmit))
3334
require.Nil(t, pluginLookup.ByKind(PluginKindHookWorkBegin))
3435
require.Nil(t, pluginLookup.ByKind(PluginKindMiddlewareJobInsert))
3536
require.Nil(t, pluginLookup.ByKind(PluginKindMiddlewareWorker))
@@ -199,6 +200,7 @@ func TestPluginLookup(t *testing.T) {
199200
lookup, isPluginLookup := NewPluginLookup([]any{
200201
&testHookInsertAndWorkBegin{},
201202
&testHookInsertBegin{},
203+
&testHookMetricEmit{},
202204
&testHookWorkBegin{},
203205
&testHookWorkEnd{},
204206
&testMiddlewareJobInsertAndWorker{},
@@ -219,6 +221,9 @@ func TestPluginLookup(t *testing.T) {
219221
&testHookInsertAndWorkBegin{},
220222
&testHookInsertBegin{},
221223
}, pluginLookup.ByKind(PluginKindHookInsertBegin))
224+
require.Equal(t, []any{
225+
&testHookMetricEmit{},
226+
}, pluginLookup.ByKind(PluginKindHookMetricEmit))
222227
require.Equal(t, []any{
223228
&testHookInsertAndWorkBegin{},
224229
&testHookWorkBegin{},
@@ -236,13 +241,16 @@ func TestPluginLookup(t *testing.T) {
236241
&testMiddlewareWorker{},
237242
}, pluginLookup.ByKind(PluginKindMiddlewareWorker))
238243

239-
require.Len(t, pluginLookup.pluginsByKind, 5)
244+
require.Len(t, pluginLookup.pluginsByKind, 6)
240245

241246
// Repeat lookups to make sure we get the same result.
242247
require.Equal(t, []any{
243248
&testHookInsertAndWorkBegin{},
244249
&testHookInsertBegin{},
245250
}, pluginLookup.ByKind(PluginKindHookInsertBegin))
251+
require.Equal(t, []any{
252+
&testHookMetricEmit{},
253+
}, pluginLookup.ByKind(PluginKindHookMetricEmit))
246254
require.Equal(t, []any{
247255
&testHookInsertAndWorkBegin{},
248256
&testHookWorkBegin{},
@@ -276,6 +284,7 @@ func TestPluginLookup(t *testing.T) {
276284
}
277285

278286
parallelLookupLoop(PluginKindHookInsertBegin)
287+
parallelLookupLoop(PluginKindHookMetricEmit)
279288
parallelLookupLoop(PluginKindHookWorkBegin)
280289
parallelLookupLoop(PluginKindHookInsertBegin)
281290
parallelLookupLoop(PluginKindHookWorkBegin)
@@ -387,6 +396,19 @@ func (t *testHookInsertBegin) InsertBegin(ctx context.Context, params *rivertype
387396
return nil
388397
}
389398

399+
//
400+
// testHookMetricEmit
401+
//
402+
403+
var _ rivertype.HookMetricEmit = &testHookMetricEmit{}
404+
405+
type testHookMetricEmit struct{ rivertype.Hook }
406+
407+
func (t *testHookMetricEmit) IsPlugin() bool { return true }
408+
409+
func (t *testHookMetricEmit) MetricEmit(ctx context.Context, params *rivertype.HookMetricEmitParams) {
410+
}
411+
390412
//
391413
// testHookMiddlewarePlugin
392414
//

producer.go

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -191,15 +191,16 @@ type producer struct {
191191
// Jobs which are currently being worked. Only used by main goroutine.
192192
activeJobs map[int64]*jobexecutor.JobExecutor
193193

194-
completer jobcompleter.JobCompleter
195-
config *producerConfig
196-
id atomic.Int64 // atomic because it's written at startup and read during shutdown
197-
exec riverdriver.Executor
198-
errorHandler jobexecutor.ErrorHandler
199-
fetchLimiter *chanutil.DebouncedChan
200-
state riverpilot.ProducerState
201-
pilot riverpilot.Pilot
202-
workers *Workers
194+
completer jobcompleter.JobCompleter
195+
config *producerConfig
196+
id atomic.Int64 // atomic because it's written at startup and read during shutdown
197+
exec riverdriver.Executor
198+
errorHandler jobexecutor.ErrorHandler
199+
fetchLimiter *chanutil.DebouncedChan
200+
metricEmitHooks []rivertype.HookMetricEmit // memoized hooks of type HookMetricEmit for reuse in dispatchWork
201+
state riverpilot.ProducerState
202+
pilot riverpilot.Pilot
203+
workers *Workers
203204

204205
// Receives job IDs to cancel. Written by notifier goroutine, only read from
205206
// main goroutine.
@@ -243,7 +244,7 @@ func newProducer(archetype *baseservice.Archetype, exec riverdriver.Executor, pi
243244
errorHandler = &errorHandlerAdapter{config.ErrorHandler}
244245
}
245246

246-
return baseservice.Init(archetype, &producer{
247+
producer := baseservice.Init(archetype, &producer{
247248
activeJobs: make(map[int64]*jobexecutor.JobExecutor),
248249
cancelCh: make(chan int64, 1000),
249250
completer: config.Completer,
@@ -257,6 +258,10 @@ func newProducer(archetype *baseservice.Archetype, exec riverdriver.Executor, pi
257258
retryPolicy: config.RetryPolicy,
258259
workers: config.Workers,
259260
})
261+
262+
producer.metricEmitHooks = producer.metricEmitHooksFromLookup()
263+
264+
return producer
260265
}
261266

262267
// Start starts the producer. It backgrounds a goroutine which is stopped when
@@ -785,7 +790,33 @@ func (p *producer) maybeCancelJob(ctx context.Context, id int64) {
785790
executor.Cancel(ctx)
786791
}
787792

793+
func (p *producer) metricEmitHooksFromLookup() []rivertype.HookMetricEmit {
794+
pluginLookup := p.config.PluginLookupGlobal
795+
if pluginLookup == nil {
796+
return nil
797+
}
798+
799+
plugins := pluginLookup.ByKind(pluginlookup.PluginKindHookMetricEmit)
800+
if len(plugins) < 1 {
801+
return nil
802+
}
803+
804+
metricEmitHooks := make([]rivertype.HookMetricEmit, len(plugins))
805+
for i, plugin := range plugins {
806+
metricEmitHooks[i] = plugin.(rivertype.HookMetricEmit) //nolint:forcetypeassert
807+
}
808+
809+
return metricEmitHooks
810+
}
811+
788812
func (p *producer) dispatchWork(workCtx context.Context, count int, fetchResultCh chan<- producerFetchResult) {
813+
// When a queue is paused, innerFetchLoop dispatches with count zero so it can
814+
// continue servicing state changes without attempting to lock jobs or emit metrics.
815+
if count <= 0 {
816+
fetchResultCh <- producerFetchResult{}
817+
return
818+
}
819+
789820
// This intentionally removes any deadlines or cancellation from the parent
790821
// context because we don't want it to get cancelled if the producer is asked
791822
// to shut down. In that situation, we want to finish fetching any jobs we are
@@ -799,6 +830,11 @@ func (p *producer) dispatchWork(workCtx context.Context, count int, fetchResultC
799830
// rarely hit, but exists to protect against degenerate cases.
800831
const maxAttemptedBy = 100
801832

833+
var startedAt time.Time
834+
if len(p.metricEmitHooks) > 0 {
835+
startedAt = time.Now()
836+
}
837+
802838
jobs, err := p.pilot.JobGetAvailable(ctx, p.exec, p.state, &riverdriver.JobGetAvailableParams{
803839
ClientID: p.config.ClientID,
804840
MaxAttemptedBy: maxAttemptedBy,
@@ -813,9 +849,30 @@ func (p *producer) dispatchWork(workCtx context.Context, count int, fetchResultC
813849
return
814850
}
815851

852+
if len(p.metricEmitHooks) > 0 {
853+
p.emitMetric(ctx, &rivertype.HookMetricEmitParams{
854+
Metric: &rivertype.JobGetAvailableDurationMetric{
855+
Duration: time.Since(startedAt),
856+
Queue: p.config.Queue,
857+
},
858+
})
859+
p.emitMetric(ctx, &rivertype.HookMetricEmitParams{
860+
Metric: &rivertype.JobGetAvailableCountMetric{
861+
Count: len(jobs),
862+
Queue: p.config.Queue,
863+
},
864+
})
865+
}
866+
816867
fetchResultCh <- producerFetchResult{jobs: jobs}
817868
}
818869

870+
func (p *producer) emitMetric(ctx context.Context, params *rivertype.HookMetricEmitParams) {
871+
for _, hook := range p.metricEmitHooks {
872+
hook.MetricEmit(ctx, params)
873+
}
874+
}
875+
819876
// Periodically logs an informational log line giving some insight into the
820877
// current state of the producer.
821878
func (p *producer) heartbeatLogLoop(ctx context.Context, wg *sync.WaitGroup) {

0 commit comments

Comments
 (0)