Skip to content

Commit e4a4b69

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 e4a4b69

8 files changed

Lines changed: 315 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: 60 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,6 +748,25 @@ 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) {
747771
// This intentionally removes any deadlines or cancellation from the parent
748772
// context because we don't want it to get cancelled if the producer is asked
@@ -757,6 +781,11 @@ func (p *producer) dispatchWork(workCtx context.Context, count int, fetchResultC
757781
// rarely hit, but exists to protect against degenerate cases.
758782
const maxAttemptedBy = 100
759783

784+
var startedAt time.Time
785+
if len(p.metricEmitHooks) > 0 {
786+
startedAt = time.Now()
787+
}
788+
760789
jobs, err := p.pilot.JobGetAvailable(ctx, p.exec, p.state, &riverdriver.JobGetAvailableParams{
761790
ClientID: p.config.ClientID,
762791
MaxAttemptedBy: maxAttemptedBy,
@@ -771,9 +800,30 @@ func (p *producer) dispatchWork(workCtx context.Context, count int, fetchResultC
771800
return
772801
}
773802

803+
if len(p.metricEmitHooks) > 0 {
804+
p.emitMetric(ctx, &rivertype.HookMetricEmitParams{
805+
Metric: &rivertype.JobGetAvailableDurationMetric{
806+
Duration: time.Since(startedAt),
807+
Queue: p.config.Queue,
808+
},
809+
})
810+
p.emitMetric(ctx, &rivertype.HookMetricEmitParams{
811+
Metric: &rivertype.JobGetAvailableCountMetric{
812+
Count: len(jobs),
813+
Queue: p.config.Queue,
814+
},
815+
})
816+
}
817+
774818
fetchResultCh <- producerFetchResult{jobs: jobs}
775819
}
776820

821+
func (p *producer) emitMetric(ctx context.Context, params *rivertype.HookMetricEmitParams) {
822+
for _, hook := range p.metricEmitHooks {
823+
hook.MetricEmit(ctx, params)
824+
}
825+
}
826+
777827
// Periodically logs an informational log line giving some insight into the
778828
// current state of the producer.
779829
func (p *producer) heartbeatLogLoop(ctx context.Context, wg *sync.WaitGroup) {

producer_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@ import (
3434

3535
const testClientID = "test-client-id"
3636

37+
type countingHookLookup struct {
38+
hooklookup.HookLookupInterface
39+
40+
count int
41+
}
42+
43+
func (l *countingHookLookup) ByHookKind(kind hooklookup.HookKind) []rivertype.Hook {
44+
l.count++
45+
return l.HookLookupInterface.ByHookKind(kind)
46+
}
47+
3748
func Test_Producer_CanSafelyCompleteJobsWhileFetchingNewOnes(t *testing.T) {
3849
// We have encountered previous data races with the list of active jobs on
3950
// Producer because we need to know the count of active jobs in order to
@@ -161,6 +172,125 @@ func Test_Producer_CanSafelyCompleteJobsWhileFetchingNewOnes(t *testing.T) {
161172
}
162173
}
163174

175+
func TestProducer_MetricEmitHook(t *testing.T) {
176+
t.Parallel()
177+
178+
ctx := context.Background()
179+
180+
type testBundle struct {
181+
archetype *baseservice.Archetype
182+
config *Config
183+
exec riverdriver.Executor
184+
hookLookup *countingHookLookup
185+
metrics chan *rivertype.HookMetricEmitParams
186+
producer *producer
187+
queue string
188+
schema string
189+
}
190+
191+
setup := func(t *testing.T) *testBundle {
192+
t.Helper()
193+
194+
var (
195+
archetype = riversharedtest.BaseServiceArchetype(t)
196+
driver = riverpgxv5.New(riversharedtest.DBPool(ctx, t))
197+
exec = driver.GetExecutor()
198+
jobUpdates = make(chan []jobcompleter.CompleterJobUpdated, 10)
199+
metrics = make(chan *rivertype.HookMetricEmitParams, 10)
200+
pilot = &riverpilot.StandardPilot{}
201+
queueName = "test_producer_metric_hook"
202+
schema = riverdbtest.TestSchema(ctx, t, driver, nil)
203+
)
204+
205+
t.Cleanup(riverinternaltest.DiscardContinuously(jobUpdates))
206+
207+
completer := jobcompleter.NewInlineCompleter(archetype, schema, exec, pilot, jobUpdates)
208+
t.Cleanup(completer.Stop)
209+
210+
metricHook := HookMetricEmitFunc(func(ctx context.Context, params *rivertype.HookMetricEmitParams) {
211+
paramsCopy := *params
212+
metrics <- &paramsCopy
213+
})
214+
hookLookup := &countingHookLookup{
215+
HookLookupInterface: hooklookup.NewHookLookup([]rivertype.Hook{metricHook}),
216+
}
217+
218+
producer := newProducer(archetype, exec, pilot, &producerConfig{
219+
ClientID: testClientID,
220+
Completer: completer,
221+
ErrorHandler: newTestErrorHandler(),
222+
FetchCooldown: FetchCooldownDefault,
223+
FetchPollInterval: 50 * time.Millisecond,
224+
HookLookupByJob: hooklookup.NewJobHookLookup(),
225+
HookLookupGlobal: hookLookup,
226+
JobTimeout: JobTimeoutDefault,
227+
MaxWorkers: 1_000,
228+
MiddlewareLookupGlobal: middlewarelookup.NewMiddlewareLookup(nil),
229+
Queue: queueName,
230+
QueuePollInterval: queuePollIntervalDefault,
231+
QueueReportInterval: queueReportIntervalDefault,
232+
RetryPolicy: &DefaultClientRetryPolicy{},
233+
SchedulerInterval: riverinternaltest.SchedulerShortInterval,
234+
Schema: schema,
235+
StaleProducerRetentionPeriod: time.Minute,
236+
Workers: NewWorkers(),
237+
})
238+
239+
return &testBundle{
240+
archetype: archetype,
241+
config: newTestConfig(t, schema),
242+
exec: exec,
243+
hookLookup: hookLookup,
244+
metrics: metrics,
245+
producer: producer,
246+
queue: queueName,
247+
schema: schema,
248+
}
249+
}
250+
251+
bundle := setup(t)
252+
253+
scheduledAt := time.Now().UTC().Add(-time.Second)
254+
insertParams := make([]*riverdriver.JobInsertFastParams, 2)
255+
for i := range insertParams {
256+
params, err := insertParamsFromConfigArgsAndOptions(bundle.archetype, bundle.config, noOpArgs{}, &InsertOpts{
257+
Queue: bundle.queue,
258+
})
259+
require.NoError(t, err)
260+
params.ScheduledAt = &scheduledAt
261+
insertParams[i] = (*riverdriver.JobInsertFastParams)(params)
262+
}
263+
264+
_, err := bundle.exec.JobInsertFastMany(ctx, &riverdriver.JobInsertFastManyParams{
265+
Jobs: insertParams,
266+
Schema: bundle.schema,
267+
})
268+
require.NoError(t, err)
269+
270+
fetchResultCh := make(chan producerFetchResult, 1)
271+
bundle.producer.dispatchWork(ctx, 2, fetchResultCh)
272+
273+
fetchResult := riversharedtest.WaitOrTimeout(t, fetchResultCh)
274+
require.NoError(t, fetchResult.err)
275+
require.Len(t, fetchResult.jobs, 2)
276+
require.Equal(t, 1, bundle.hookLookup.count)
277+
278+
metricsByName := make(map[rivertype.MetricName]rivertype.Metric)
279+
for _, metric := range riversharedtest.WaitOrTimeoutN(t, bundle.metrics, 2) {
280+
metricsByName[metric.Metric.Name()] = metric.Metric
281+
}
282+
283+
durationMetric, durationMetricFound := metricsByName[rivertype.MetricNameJobGetAvailableDuration].(*rivertype.JobGetAvailableDurationMetric)
284+
require.True(t, durationMetricFound)
285+
require.Equal(t, bundle.queue, durationMetric.Queue)
286+
require.GreaterOrEqual(t, durationMetric.Duration, time.Duration(0))
287+
288+
countMetric, countMetricFound := metricsByName[rivertype.MetricNameJobGetAvailableCount].(*rivertype.JobGetAvailableCountMetric)
289+
require.True(t, countMetricFound)
290+
require.Equal(t, bundle.queue, countMetric.Queue)
291+
require.Equal(t, 2, countMetric.Count)
292+
}
293+
164294
func TestProducer_PollOnly(t *testing.T) {
165295
t.Parallel()
166296

0 commit comments

Comments
 (0)