Skip to content

Commit 1717713

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 f2512a7 commit 1717713

8 files changed

Lines changed: 341 additions & 16 deletions

File tree

client.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,11 @@ type Config struct {
204204
// lifecycle (see rivertype.Hook), installed globally.
205205
//
206206
// The effect of hooks in this list will depend on the specific hook
207-
// interfaces they implement, so for example implementing
208-
// rivertype.HookInsertBegin will cause the hook to be invoked before a job
209-
// is inserted, or implementing rivertype.HookWorkBegin will cause it to be
210-
// invoked before a job is worked. Hook structs may implement multiple hook
211-
// interfaces.
207+
// interfaces they implement. rivertype.HookInsertBegin will cause the hook
208+
// to be invoked before a job is inserted. rivertype.HookMetricEmit will
209+
// cause the hook to be invoked when River emits a metric. Implementing
210+
// rivertype.HookWorkBegin will cause it to be invoked before a job is
211+
// worked. Hook structs may implement multiple hook interfaces.
212212
//
213213
// Order in this list is significant. A hook that appears first will be
214214
// entered before a hook that appears later. For any particular phase, order

hook_defaults_funcs.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ func (f HookInsertBeginFunc) InsertBegin(ctx context.Context, params *rivertype.
2323

2424
func (f HookInsertBeginFunc) IsHook() bool { return true }
2525

26+
// HookMetricEmitFunc is a convenience helper for implementing
27+
// rivertype.HookMetricEmit using a simple function instead of a struct.
28+
type HookMetricEmitFunc func(ctx context.Context, params *rivertype.HookMetricEmitParams)
29+
30+
func (f HookMetricEmitFunc) IsHook() bool { return true }
31+
32+
func (f HookMetricEmitFunc) MetricEmit(ctx context.Context, params *rivertype.HookMetricEmitParams) {
33+
f(ctx, params)
34+
}
35+
2636
// HookPeriodicJobsStartFunc is a convenience helper for implementing
2737
// rivertype.HookPeriodicJobsStart using a simple function instead of a struct.
2838
type HookPeriodicJobsStartFunc func(ctx context.Context, params *rivertype.HookPeriodicJobsStartParams) error

hook_defaults_funcs_test.go

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

14+
_ rivertype.Hook = HookMetricEmitFunc(func(ctx context.Context, params *rivertype.HookMetricEmitParams) {})
15+
_ rivertype.HookMetricEmit = HookMetricEmitFunc(func(ctx context.Context, params *rivertype.HookMetricEmitParams) {})
16+
1417
_ rivertype.Hook = HookPeriodicJobsStartFunc(func(ctx context.Context, params *rivertype.HookPeriodicJobsStartParams) error { return nil })
1518
_ rivertype.HookPeriodicJobsStart = HookPeriodicJobsStartFunc(func(ctx context.Context, params *rivertype.HookPeriodicJobsStartParams) error { return nil })
1619

1720
_ rivertype.Hook = HookWorkBeginFunc(func(ctx context.Context, job *rivertype.JobRow) error { return nil })
1821
_ rivertype.HookWorkBegin = HookWorkBeginFunc(func(ctx context.Context, job *rivertype.JobRow) error { return nil })
22+
23+
_ rivertype.Hook = HookWorkEndFunc(func(ctx context.Context, job *rivertype.JobRow, err error) error { return err })
24+
_ rivertype.HookWorkEnd = HookWorkEndFunc(func(ctx context.Context, job *rivertype.JobRow, err error) error { return err })
1925
)

internal/hooklookup/hook_lookup.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type HookKind string
1414

1515
const (
1616
HookKindInsertBegin HookKind = "insert_begin"
17+
HookKindMetricEmit HookKind = "metric_emit"
1718
HookKindPeriodicJobsStart HookKind = "periodic_job_start"
1819
HookKindWorkBegin HookKind = "work_begin"
1920
HookKindWorkEnd HookKind = "work_end"
@@ -84,6 +85,12 @@ func (c *hookLookup) ByHookKind(kind HookKind) []rivertype.Hook {
8485
c.hooksByKind[kind] = append(c.hooksByKind[kind], typedHook)
8586
}
8687
}
88+
case HookKindMetricEmit:
89+
for _, hook := range c.hooks {
90+
if typedHook, ok := hook.(rivertype.HookMetricEmit); ok {
91+
c.hooksByKind[kind] = append(c.hooksByKind[kind], typedHook)
92+
}
93+
}
8794
case HookKindPeriodicJobsStart:
8895
for _, hook := range c.hooks {
8996
if typedHook, ok := hook.(rivertype.HookPeriodicJobsStart); ok {

internal/hooklookup/hook_lookup_test.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func TestHookLookup(t *testing.T) {
2121
return NewHookLookup([]rivertype.Hook{ //nolint:forcetypeassert
2222
&testHookInsertAndWorkBegin{},
2323
&testHookInsertBegin{},
24+
&testHookMetricEmit{},
2425
&testHookWorkBegin{},
2526
&testHookWorkEnd{},
2627
}).(*hookLookup), &testBundle{}
@@ -35,6 +36,9 @@ func TestHookLookup(t *testing.T) {
3536
&testHookInsertAndWorkBegin{},
3637
&testHookInsertBegin{},
3738
}, hookLookup.ByHookKind(HookKindInsertBegin))
39+
require.Equal(t, []rivertype.Hook{
40+
&testHookMetricEmit{},
41+
}, hookLookup.ByHookKind(HookKindMetricEmit))
3842
require.Equal(t, []rivertype.Hook{
3943
&testHookInsertAndWorkBegin{},
4044
&testHookWorkBegin{},
@@ -43,13 +47,16 @@ func TestHookLookup(t *testing.T) {
4347
&testHookWorkEnd{},
4448
}, hookLookup.ByHookKind(HookKindWorkEnd))
4549

46-
require.Len(t, hookLookup.hooksByKind, 3)
50+
require.Len(t, hookLookup.hooksByKind, 4)
4751

4852
// Repeat lookups to make sure we get the same result.
4953
require.Equal(t, []rivertype.Hook{
5054
&testHookInsertAndWorkBegin{},
5155
&testHookInsertBegin{},
5256
}, hookLookup.ByHookKind(HookKindInsertBegin))
57+
require.Equal(t, []rivertype.Hook{
58+
&testHookMetricEmit{},
59+
}, hookLookup.ByHookKind(HookKindMetricEmit))
5360
require.Equal(t, []rivertype.Hook{
5461
&testHookInsertAndWorkBegin{},
5562
&testHookWorkBegin{},
@@ -75,6 +82,7 @@ func TestHookLookup(t *testing.T) {
7582
}
7683

7784
parallelLookupLoop(HookKindInsertBegin)
85+
parallelLookupLoop(HookKindMetricEmit)
7886
parallelLookupLoop(HookKindWorkBegin)
7987
parallelLookupLoop(HookKindInsertBegin)
8088
parallelLookupLoop(HookKindWorkBegin)
@@ -100,6 +108,7 @@ func TestEmptyHookLookup(t *testing.T) {
100108
hookLookup, _ := setup(t)
101109

102110
require.Nil(t, hookLookup.ByHookKind(HookKindInsertBegin))
111+
require.Nil(t, hookLookup.ByHookKind(HookKindMetricEmit))
103112
require.Nil(t, hookLookup.ByHookKind(HookKindWorkBegin))
104113
})
105114
}
@@ -241,6 +250,17 @@ func (t *testHookInsertBegin) InsertBegin(ctx context.Context, params *rivertype
241250
return nil
242251
}
243252

253+
//
254+
// testHookMetricEmit
255+
//
256+
257+
var _ rivertype.HookMetricEmit = &testHookMetricEmit{}
258+
259+
type testHookMetricEmit struct{ rivertype.Hook }
260+
261+
func (t *testHookMetricEmit) MetricEmit(ctx context.Context, params *rivertype.HookMetricEmitParams) {
262+
}
263+
244264
//
245265
// testHookWorkBegin
246266
//

producer.go

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

184-
completer jobcompleter.JobCompleter
185-
config *producerConfig
186-
id atomic.Int64 // atomic because it's written at startup and read during shutdown
187-
exec riverdriver.Executor
188-
errorHandler jobexecutor.ErrorHandler
189-
fetchLimiter *chanutil.DebouncedChan
190-
state riverpilot.ProducerState
191-
pilot riverpilot.Pilot
192-
workers *Workers
184+
completer jobcompleter.JobCompleter
185+
config *producerConfig
186+
id atomic.Int64 // atomic because it's written at startup and read during shutdown
187+
exec riverdriver.Executor
188+
errorHandler jobexecutor.ErrorHandler
189+
fetchLimiter *chanutil.DebouncedChan
190+
metricEmitHooks []rivertype.HookMetricEmit // memoized hooks of type HookMetricEmit for reuse in dispatchWork
191+
state riverpilot.ProducerState
192+
pilot riverpilot.Pilot
193+
workers *Workers
193194

194195
// Receives job IDs to cancel. Written by notifier goroutine, only read from
195196
// main goroutine.
@@ -233,7 +234,7 @@ func newProducer(archetype *baseservice.Archetype, exec riverdriver.Executor, pi
233234
errorHandler = &errorHandlerAdapter{config.ErrorHandler}
234235
}
235236

236-
return baseservice.Init(archetype, &producer{
237+
producer := baseservice.Init(archetype, &producer{
237238
activeJobs: make(map[int64]*jobexecutor.JobExecutor),
238239
cancelCh: make(chan int64, 1000),
239240
completer: config.Completer,
@@ -247,6 +248,10 @@ func newProducer(archetype *baseservice.Archetype, exec riverdriver.Executor, pi
247248
retryPolicy: config.RetryPolicy,
248249
workers: config.Workers,
249250
})
251+
252+
producer.metricEmitHooks = producer.metricEmitHooksFromLookup()
253+
254+
return producer
250255
}
251256

252257
// Start starts the producer. It backgrounds a goroutine which is stopped when
@@ -743,7 +748,33 @@ func (p *producer) maybeCancelJob(ctx context.Context, id int64) {
743748
executor.Cancel(ctx)
744749
}
745750

751+
func (p *producer) metricEmitHooksFromLookup() []rivertype.HookMetricEmit {
752+
hookLookup := p.config.HookLookupGlobal
753+
if hookLookup == nil {
754+
return nil
755+
}
756+
757+
hooks := hookLookup.ByHookKind(hooklookup.HookKindMetricEmit)
758+
if len(hooks) < 1 {
759+
return nil
760+
}
761+
762+
metricEmitHooks := make([]rivertype.HookMetricEmit, len(hooks))
763+
for i, hook := range hooks {
764+
metricEmitHooks[i] = hook.(rivertype.HookMetricEmit) //nolint:forcetypeassert
765+
}
766+
767+
return metricEmitHooks
768+
}
769+
746770
func (p *producer) dispatchWork(workCtx context.Context, count int, fetchResultCh chan<- producerFetchResult) {
771+
// When a queue is paused, innerFetchLoop dispatches with count zero so it can
772+
// continue servicing state changes without attempting to lock jobs or emit metrics.
773+
if count <= 0 {
774+
fetchResultCh <- producerFetchResult{}
775+
return
776+
}
777+
747778
// This intentionally removes any deadlines or cancellation from the parent
748779
// context because we don't want it to get cancelled if the producer is asked
749780
// to shut down. In that situation, we want to finish fetching any jobs we are
@@ -757,6 +788,11 @@ func (p *producer) dispatchWork(workCtx context.Context, count int, fetchResultC
757788
// rarely hit, but exists to protect against degenerate cases.
758789
const maxAttemptedBy = 100
759790

791+
var startedAt time.Time
792+
if len(p.metricEmitHooks) > 0 {
793+
startedAt = time.Now()
794+
}
795+
760796
jobs, err := p.pilot.JobGetAvailable(ctx, p.exec, p.state, &riverdriver.JobGetAvailableParams{
761797
ClientID: p.config.ClientID,
762798
MaxAttemptedBy: maxAttemptedBy,
@@ -771,9 +807,30 @@ func (p *producer) dispatchWork(workCtx context.Context, count int, fetchResultC
771807
return
772808
}
773809

810+
if len(p.metricEmitHooks) > 0 {
811+
p.emitMetric(ctx, &rivertype.HookMetricEmitParams{
812+
Metric: &rivertype.JobGetAvailableDurationMetric{
813+
Duration: time.Since(startedAt),
814+
Queue: p.config.Queue,
815+
},
816+
})
817+
p.emitMetric(ctx, &rivertype.HookMetricEmitParams{
818+
Metric: &rivertype.JobGetAvailableCountMetric{
819+
Count: len(jobs),
820+
Queue: p.config.Queue,
821+
},
822+
})
823+
}
824+
774825
fetchResultCh <- producerFetchResult{jobs: jobs}
775826
}
776827

828+
func (p *producer) emitMetric(ctx context.Context, params *rivertype.HookMetricEmitParams) {
829+
for _, hook := range p.metricEmitHooks {
830+
hook.MetricEmit(ctx, params)
831+
}
832+
}
833+
777834
// Periodically logs an informational log line giving some insight into the
778835
// current state of the producer.
779836
func (p *producer) heartbeatLogLoop(ctx context.Context, wg *sync.WaitGroup) {

0 commit comments

Comments
 (0)