master
go 2,096 lines 56.8 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package jobruntime
4
5 import (
6 "bytes"
7 "context"
8 "errors"
9 "fmt"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
15 "github.com/netdata/netdata/go/plugins/plugin/framework/chartemit"
16 "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
17 "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
18 "github.com/netdata/netdata/go/plugins/plugin/framework/runtimecomp"
19 "github.com/netdata/netdata/go/plugins/plugin/framework/vnoderegistry"
20 "github.com/netdata/netdata/go/plugins/plugin/framework/vnodes"
21 "github.com/stretchr/testify/assert"
22 "github.com/stretchr/testify/require"
23
24 "github.com/netdata/netdata/go/plugins/pkg/metrix"
25 )
26
27 type mockModuleV2 struct {
28 collectorapi.Base
29
30 initFunc func(context.Context) error
31 checkFunc func(context.Context) error
32 collectFunc func(context.Context) error
33 cleanupFunc func(context.Context)
34
35 store metrix.CollectorStore
36 template string
37 templateCalls int
38 cleaned bool
39 vnode *vnodes.VirtualNode
40 }
41
42 type mockRuntimeComponentService struct {
43 registerErr error
44 registered []runtimecomp.ComponentConfig
45 unregistered []string
46 }
47
48 type writeFunc func([]byte) (int, error)
49
50 func (f writeFunc) Write(p []byte) (int, error) {
51 return f(p)
52 }
53
54 func (m *mockRuntimeComponentService) RegisterComponent(cfg runtimecomp.ComponentConfig) error {
55 if m.registerErr != nil {
56 return m.registerErr
57 }
58 m.registered = append(m.registered, cfg)
59 return nil
60 }
61
62 func (m *mockRuntimeComponentService) UnregisterComponent(name string) {
63 m.unregistered = append(m.unregistered, name)
64 }
65
66 func (m *mockRuntimeComponentService) RegisterProducer(_ string, _ func() error) error {
67 return nil
68 }
69
70 func (m *mockRuntimeComponentService) UnregisterProducer(_ string) {}
71
72 func (m *mockModuleV2) Init(ctx context.Context) error {
73 if m.initFunc == nil {
74 return nil
75 }
76 return m.initFunc(ctx)
77 }
78
79 func (m *mockModuleV2) Check(ctx context.Context) error {
80 if m.checkFunc == nil {
81 return nil
82 }
83 return m.checkFunc(ctx)
84 }
85
86 func (m *mockModuleV2) Collect(ctx context.Context) error {
87 if m.collectFunc == nil {
88 return nil
89 }
90 return m.collectFunc(ctx)
91 }
92
93 func (m *mockModuleV2) Cleanup(ctx context.Context) {
94 if m.cleanupFunc != nil {
95 m.cleanupFunc(ctx)
96 }
97 m.cleaned = true
98 }
99
100 func (m *mockModuleV2) Configuration() any { return nil }
101 func (m *mockModuleV2) VirtualNode() *vnodes.VirtualNode { return m.vnode }
102 func (m *mockModuleV2) MetricStore() metrix.CollectorStore { return m.store }
103 func (m *mockModuleV2) ChartTemplateYAML() string {
104 m.templateCalls++
105 return m.template
106 }
107
108 func newTestJobV2(mod collectorapi.CollectorV2, out *bytes.Buffer) *JobV2 {
109 return NewJobV2(JobV2Config{
110 PluginName: pluginName,
111 Name: jobName,
112 ModuleName: modName,
113 FullName: modName + "_" + jobName,
114 Module: mod,
115 Out: out,
116 UpdateEvery: 1,
117 Labels: map[string]string{
118 "instance": "localhost",
119 },
120 })
121 }
122
123 func newTestJobV2WithVnode(mod collectorapi.CollectorV2, out *bytes.Buffer, vnode vnodes.VirtualNode) *JobV2 {
124 return NewJobV2(JobV2Config{
125 PluginName: pluginName,
126 Name: jobName,
127 ModuleName: modName,
128 FullName: modName + "_" + jobName,
129 Module: mod,
130 Out: out,
131 UpdateEvery: 1,
132 Labels: map[string]string{
133 "instance": "localhost",
134 },
135 Vnode: vnode,
136 })
137 }
138
139 func newRegistryTestJobV2(t *testing.T, fullName string, registry *vnoderegistry.Registry, out *bytes.Buffer, vnode vnodes.VirtualNode) *JobV2 {
140 t.Helper()
141 store := metrix.NewCollectorStore()
142 mod := &mockModuleV2{
143 store: store,
144 template: chartTemplateV2(),
145 collectFunc: func(context.Context) error {
146 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
147 return nil
148 },
149 }
150 job := NewJobV2(JobV2Config{
151 PluginName: pluginName,
152 Name: fullName,
153 ModuleName: modName,
154 FullName: fullName,
155 Module: mod,
156 Out: out,
157 UpdateEvery: 1,
158 Vnode: vnode,
159 VnodeRegistry: registry,
160 })
161 require.NoError(t, job.AutoDetection())
162 return job
163 }
164
165 func requireDefaultScopeState(t *testing.T, job *JobV2) *jobV2ScopeState {
166 t.Helper()
167 state := job.scopeStates[defaultHostScopeKey]
168 require.NotNil(t, state)
169 return state
170 }
171
172 func chartTemplateV2() string {
173 return `
174 version: v1
175 groups:
176 - family: Workers
177 metrics:
178 - apache.workers_busy
179 charts:
180 - id: workers_busy
181 title: Workers Busy
182 context: workers_busy
183 units: workers
184 dimensions:
185 - selector: apache.workers_busy
186 name: busy
187 `
188 }
189
190 func chartTemplateV2ExpireAfterOne() string {
191 return `
192 version: v1
193 groups:
194 - family: Workers
195 metrics:
196 - apache.workers_busy
197 charts:
198 - id: workers_busy
199 title: Workers Busy
200 context: workers_busy
201 units: workers
202 lifecycle:
203 expire_after_cycles: 1
204 dimensions:
205 - selector: apache.workers_busy
206 name: busy
207 `
208 }
209
210 func chartTemplateV2Dynamic() string {
211 return `
212 version: v1
213 groups:
214 - family: Net
215 metrics:
216 - windows_net_bytes_received_total
217 - windows_net_bytes_sent_total
218 charts:
219 - id: win_nic_traffic
220 title: NIC traffic
221 context: nic_traffic
222 units: bytes/s
223 instances:
224 by_labels: [nic]
225 dimensions:
226 - selector: windows_net_bytes_received_total
227 name: received
228 - selector: windows_net_bytes_sent_total
229 name: sent
230 `
231 }
232
233 func TestJobV2Scenarios(t *testing.T) {
234 tests := map[string]struct {
235 run func(t *testing.T)
236 }{
237 "auto detection succeeds with valid store/template": {
238 run: func(t *testing.T) {
239 mod := &mockModuleV2{
240 store: metrix.NewCollectorStore(),
241 template: chartTemplateV2(),
242 }
243 job := newTestJobV2(mod, &bytes.Buffer{})
244 require.NoError(t, job.AutoDetection())
245 require.NotNil(t, job.store)
246 require.NotNil(t, job.cycle)
247 state, err := job.ensureScopeState(metrix.HostScope{})
248 require.NoError(t, err)
249 require.NotNil(t, state.engine)
250 attempt, err := state.engine.PreparePlan(job.store.Read(metrix.ReadFlatten()))
251 require.NoError(t, err)
252 defer attempt.Abort()
253 err = attempt.Commit()
254 require.NoError(t, err)
255 },
256 },
257 "auto detection fails when metric store is nil": {
258 run: func(t *testing.T) {
259 mod := &mockModuleV2{
260 store: nil,
261 template: chartTemplateV2(),
262 }
263 job := newTestJobV2(mod, &bytes.Buffer{})
264 require.ErrorContains(t, job.AutoDetection(), "nil metric store")
265 },
266 },
267 "runOnce collects and emits chart actions": {
268 run: func(t *testing.T) {
269 store := metrix.NewCollectorStore()
270 mod := &mockModuleV2{
271 store: store,
272 template: chartTemplateV2(),
273 collectFunc: func(context.Context) error {
274 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(7)
275 return nil
276 },
277 }
278
279 var out bytes.Buffer
280 job := newTestJobV2(mod, &out)
281 require.NoError(t, job.AutoDetection())
282 job.runOnce()
283
284 wire := out.String()
285 assert.Contains(t, wire, fmt.Sprintf(`HOST ''
286
287 CHART 'module_job.workers_busy' '' 'Workers Busy' 'workers' 'Workers' 'workers_busy' 'line' '%d' '1' '' 'plugin' 'module'
288 CLABEL 'instance' 'localhost' '2'
289 CLABEL '_collect_job' 'job' '1'
290 CLABEL_COMMIT
291 DIMENSION 'busy' 'busy' 'absolute' '1' '1' ''
292 BEGIN 'module_job.workers_busy'
293 SET 'busy' = 7
294 END`, chartengine.Priority))
295 assert.False(t, job.Panicked())
296 },
297 },
298 "collect error aborts cycle and emits nothing": {
299 run: func(t *testing.T) {
300 store := metrix.NewCollectorStore()
301 mod := &mockModuleV2{
302 store: store,
303 template: chartTemplateV2(),
304 collectFunc: func(context.Context) error {
305 return errors.New("collect failed")
306 },
307 }
308
309 var out bytes.Buffer
310 job := newTestJobV2(mod, &out)
311 require.NoError(t, job.AutoDetection())
312 job.runOnce()
313
314 assert.Equal(t, "", out.String())
315 assert.Equal(t, metrix.CollectStatusFailed, store.Read(metrix.ReadRaw()).CollectMeta().LastAttemptStatus)
316 },
317 },
318 "panic in collect aborts active cycle and next cycle still succeeds": {
319 run: func(t *testing.T) {
320 store := metrix.NewCollectorStore()
321 collectCalls := 0
322 mod := &mockModuleV2{
323 store: store,
324 template: chartTemplateV2(),
325 collectFunc: func(context.Context) error {
326 collectCalls++
327 if collectCalls == 1 {
328 panic("boom")
329 }
330 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(11)
331 return nil
332 },
333 }
334
335 var out bytes.Buffer
336 job := newTestJobV2(mod, &out)
337 require.NoError(t, job.AutoDetection())
338
339 job.runOnce()
340 assert.True(t, job.Panicked())
341 assert.Equal(t, metrix.CollectStatusFailed, store.Read(metrix.ReadRaw()).CollectMeta().LastAttemptStatus)
342 out.Reset()
343
344 job.runOnce()
345 assert.False(t, job.Panicked())
346 assert.Equal(t, metrix.CollectStatusSuccess, store.Read(metrix.ReadRaw()).CollectMeta().LastAttemptStatus)
347 assert.Contains(t, out.String(), "SET 'busy' = 11")
348 },
349 },
350 "runOnce materializes dynamic chart instances from labels": {
351 run: func(t *testing.T) {
352 store := metrix.NewCollectorStore()
353 mod := &mockModuleV2{
354 store: store,
355 template: chartTemplateV2Dynamic(),
356 collectFunc: func(context.Context) error {
357 sm := store.Write().SnapshotMeter("")
358 rx := sm.Counter("windows_net_bytes_received_total")
359 tx := sm.Counter("windows_net_bytes_sent_total")
360
361 eth0 := sm.LabelSet(metrix.Label{Key: "nic", Value: "eth0"})
362 eth1 := sm.LabelSet(metrix.Label{Key: "nic", Value: "eth1"})
363
364 rx.ObserveTotal(100, eth0)
365 tx.ObserveTotal(80, eth0)
366 rx.ObserveTotal(50, eth1)
367 tx.ObserveTotal(40, eth1)
368 return nil
369 },
370 }
371
372 var out bytes.Buffer
373 job := newTestJobV2(mod, &out)
374 require.NoError(t, job.AutoDetection())
375 job.runOnce()
376
377 wire := out.String()
378 assert.Contains(t, wire, fmt.Sprintf(`CHART 'module_job.win_nic_traffic_eth0' '' 'NIC traffic' 'bytes/s' 'Net' 'nic_traffic' 'line' '%d' '1' '' 'plugin' 'module'
379 CLABEL 'instance' 'localhost' '2'
380 CLABEL 'nic' 'eth0' '1'
381 CLABEL '_collect_job' 'job' '1'
382 CLABEL_COMMIT
383 DIMENSION 'received' 'received' 'incremental' '1' '1' ''
384 DIMENSION 'sent' 'sent' 'incremental' '1' '1' ''
385 CHART 'module_job.win_nic_traffic_eth1' '' 'NIC traffic' 'bytes/s' 'Net' 'nic_traffic' 'line' '%d' '1' '' 'plugin' 'module'
386 CLABEL 'instance' 'localhost' '2'
387 CLABEL 'nic' 'eth1' '1'
388 CLABEL '_collect_job' 'job' '1'
389 CLABEL_COMMIT
390 DIMENSION 'received' 'received' 'incremental' '1' '1' ''
391 DIMENSION 'sent' 'sent' 'incremental' '1' '1' ''
392 BEGIN 'module_job.win_nic_traffic_eth0'
393 SET 'received' = 100
394 SET 'sent' = 80
395 END
396
397 BEGIN 'module_job.win_nic_traffic_eth1'
398 SET 'received' = 50
399 SET 'sent' = 40
400 END`, chartengine.Priority, chartengine.Priority))
401 },
402 },
403 "runtime component registers on successful autodetection": {
404 run: func(t *testing.T) {
405 store := metrix.NewCollectorStore()
406 runtimeSvc := &mockRuntimeComponentService{}
407 mod := &mockModuleV2{
408 store: store,
409 template: chartTemplateV2(),
410 }
411
412 job := NewJobV2(JobV2Config{
413 PluginName: pluginName,
414 Name: jobName,
415 ModuleName: modName,
416 FullName: modName + "_" + jobName,
417 Module: mod,
418 Out: &bytes.Buffer{},
419 UpdateEvery: 3,
420 RuntimeService: runtimeSvc,
421 })
422
423 require.NoError(t, job.AutoDetection())
424 require.Len(t, runtimeSvc.registered, 1)
425 cfg := runtimeSvc.registered[0]
426 assert.Equal(t, job.runtimeComponentName, cfg.Name)
427 assert.True(t, cfg.Autogen.Enabled)
428 assert.Equal(t, 3, cfg.UpdateEvery)
429 assert.Equal(t, pluginName, cfg.Plugin)
430 assert.Equal(t, "chartengine", cfg.Module)
431 assert.Equal(t, jobName, cfg.JobName)
432 assert.Equal(t, modName, cfg.JobLabels["_collect_module"])
433 assert.NotContains(t, cfg.JobLabels, "source")
434 assert.NotContains(t, cfg.JobLabels, "collector_module")
435 require.NotNil(t, cfg.Store)
436 assert.Equal(t, job.runtimeStore, cfg.Store)
437 },
438 },
439 "module context carries runtime component service when available": {
440 run: func(t *testing.T) {
441 store := metrix.NewCollectorStore()
442 runtimeSvc := &mockRuntimeComponentService{}
443 mod := &mockModuleV2{
444 store: store,
445 template: chartTemplateV2(),
446 initFunc: func(ctx context.Context) error {
447 got, ok := runtimecomp.ServiceFromContext(ctx)
448 require.True(t, ok)
449 require.NotNil(t, got)
450 assert.Same(t, runtimeSvc, got)
451 return nil
452 },
453 }
454
455 job := NewJobV2(JobV2Config{
456 PluginName: pluginName,
457 Name: jobName,
458 ModuleName: modName,
459 FullName: modName + "_" + jobName,
460 Module: mod,
461 Out: &bytes.Buffer{},
462 UpdateEvery: 1,
463 RuntimeService: runtimeSvc,
464 })
465
466 require.NoError(t, job.AutoDetection())
467 },
468 },
469 "runtime registration failure is non-fatal for autodetection": {
470 run: func(t *testing.T) {
471 store := metrix.NewCollectorStore()
472 runtimeSvc := &mockRuntimeComponentService{registerErr: errors.New("register failed")}
473 mod := &mockModuleV2{
474 store: store,
475 template: chartTemplateV2(),
476 }
477 job := NewJobV2(JobV2Config{
478 PluginName: pluginName,
479 Name: jobName,
480 ModuleName: modName,
481 FullName: modName + "_" + jobName,
482 Module: mod,
483 Out: &bytes.Buffer{},
484 UpdateEvery: 1,
485 RuntimeService: runtimeSvc,
486 })
487
488 require.NoError(t, job.AutoDetection())
489 assert.False(t, job.runtimeComponentRegistered)
490 assert.Empty(t, runtimeSvc.registered)
491 },
492 },
493 "cleanup unregisters runtime component": {
494 run: func(t *testing.T) {
495 store := metrix.NewCollectorStore()
496 runtimeSvc := &mockRuntimeComponentService{}
497 mod := &mockModuleV2{
498 store: store,
499 template: chartTemplateV2(),
500 }
501 job := NewJobV2(JobV2Config{
502 PluginName: pluginName,
503 Name: jobName,
504 ModuleName: modName,
505 FullName: modName + "_" + jobName,
506 Module: mod,
507 Out: &bytes.Buffer{},
508 UpdateEvery: 1,
509 RuntimeService: runtimeSvc,
510 })
511
512 require.NoError(t, job.AutoDetection())
513 require.True(t, job.runtimeComponentRegistered)
514 componentName := job.runtimeComponentName
515
516 job.Cleanup()
517 assert.False(t, job.runtimeComponentRegistered)
518 assert.Contains(t, runtimeSvc.unregistered, componentName)
519 },
520 },
521 "panic cycle drops buffered partial output": {
522 run: func(t *testing.T) {
523 store := metrix.NewCollectorStore()
524 mod := &mockModuleV2{
525 store: store,
526 template: chartTemplateV2(),
527 collectFunc: func(context.Context) error {
528 panic("boom")
529 },
530 }
531
532 var out bytes.Buffer
533 job := newTestJobV2(mod, &out)
534 require.NoError(t, job.AutoDetection())
535
536 // Simulate partial protocol bytes already present in the cycle buffer.
537 _, err := job.buf.WriteString("BEGIN 'broken'\nSET 'x' = 1\n")
538 require.NoError(t, err)
539
540 job.runOnce()
541 assert.True(t, job.Panicked())
542 assert.Equal(t, "", out.String())
543 assert.Zero(t, job.buf.Len())
544 },
545 },
546 "module-owned vnode is not overridden by queued job vnode updates": {
547 run: func(t *testing.T) {
548 store := metrix.NewCollectorStore()
549 mod := &mockModuleV2{
550 store: store,
551 template: chartTemplateV2(),
552 vnode: &vnodes.VirtualNode{
553 Name: "old",
554 Hostname: "old-host",
555 GUID: "old-guid",
556 },
557 }
558
559 collectStarted := make(chan struct{})
560 collectRelease := make(chan struct{})
561 firstCollectDone := make(chan struct{})
562 collectCalls := 0
563
564 mod.collectFunc = func(context.Context) error {
565 collectCalls++
566 if collectCalls == 1 {
567 close(collectStarted)
568 <-collectRelease
569 assert.Equal(t, "old-guid", mod.vnode.GUID)
570 close(firstCollectDone)
571 } else {
572 assert.Equal(t, "old-guid", mod.vnode.GUID)
573 }
574 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
575 return nil
576 }
577
578 job := newTestJobV2WithVnode(mod, &bytes.Buffer{}, *mod.vnode.Copy())
579 require.NoError(t, job.AutoDetection())
580
581 runDone := make(chan struct{})
582 go func() {
583 job.runOnce()
584 close(runDone)
585 }()
586
587 <-collectStarted
588 job.UpdateVnode(&vnodes.VirtualNode{
589 Name: "new",
590 Hostname: "new-host",
591 GUID: "new-guid",
592 })
593 assert.Equal(t, "old-guid", mod.vnode.GUID)
594
595 close(collectRelease)
596 <-firstCollectDone
597 <-runDone
598
599 job.runOnce()
600 assert.Equal(t, "old-guid", mod.vnode.GUID)
601 assert.Equal(t, "old-guid", job.Vnode().GUID)
602 },
603 },
604 "stop cancels in-flight collect context": {
605 run: func(t *testing.T) {
606 store := metrix.NewCollectorStore()
607 collectCtxCh := make(chan context.Context, 1)
608 mod := &mockModuleV2{
609 store: store,
610 template: chartTemplateV2(),
611 collectFunc: func(ctx context.Context) error {
612 collectCtxCh <- ctx
613 <-ctx.Done()
614 return ctx.Err()
615 },
616 }
617
618 job := newTestJobV2(mod, &bytes.Buffer{})
619 require.NoError(t, job.AutoDetection())
620
621 startDone := make(chan struct{})
622 go func() {
623 job.Start()
624 close(startDone)
625 }()
626
627 var collectCtx context.Context
628 deadline := time.After(2 * time.Second)
629 WAIT_COLLECT:
630 for {
631 job.Tick(1)
632 select {
633 case collectCtx = <-collectCtxCh:
634 break WAIT_COLLECT
635 case <-time.After(10 * time.Millisecond):
636 case <-deadline:
637 t.Fatal("collect did not start")
638 }
639 }
640
641 select {
642 case <-collectCtx.Done():
643 t.Fatal("collect context canceled before stop")
644 default:
645 }
646
647 stopDone := make(chan struct{})
648 go func() {
649 job.Stop()
650 close(stopDone)
651 }()
652
653 select {
654 case <-collectCtx.Done():
655 case <-time.After(time.Second):
656 t.Fatal("collect context was not canceled on stop")
657 }
658
659 select {
660 case <-stopDone:
661 case <-time.After(time.Second):
662 t.Fatal("stop did not finish")
663 }
664
665 select {
666 case <-startDone:
667 case <-time.After(time.Second):
668 t.Fatal("job start loop did not exit")
669 }
670 },
671 },
672 "autodetection init failure disables retry": {
673 run: func(t *testing.T) {
674 mod := &mockModuleV2{
675 initFunc: func(context.Context) error { return errors.New("init failed") },
676 }
677 job := NewJobV2(JobV2Config{
678 PluginName: pluginName,
679 Name: jobName,
680 ModuleName: modName,
681 FullName: modName + "_" + jobName,
682 Module: mod,
683 Out: &bytes.Buffer{},
684 UpdateEvery: 1,
685 AutoDetectEvery: 1,
686 })
687
688 require.Error(t, job.AutoDetection())
689 assert.False(t, job.RetryAutoDetection())
690 },
691 },
692 "autodetection panic disables retry": {
693 run: func(t *testing.T) {
694 mod := &mockModuleV2{
695 initFunc: func(context.Context) error { panic("boom") },
696 }
697 job := NewJobV2(JobV2Config{
698 PluginName: pluginName,
699 Name: jobName,
700 ModuleName: modName,
701 FullName: modName + "_" + jobName,
702 Module: mod,
703 Out: &bytes.Buffer{},
704 UpdateEvery: 1,
705 AutoDetectEvery: 1,
706 })
707
708 require.Error(t, job.AutoDetection())
709 assert.False(t, job.RetryAutoDetection())
710 },
711 },
712 "function-only mode skips collect loop": {
713 run: func(t *testing.T) {
714 collectCalls := 0
715 mod := &mockModuleV2{
716 collectFunc: func(context.Context) error {
717 collectCalls++
718 return nil
719 },
720 }
721 job := NewJobV2(JobV2Config{
722 PluginName: pluginName,
723 Name: jobName,
724 ModuleName: modName,
725 FullName: modName + "_" + jobName,
726 Module: mod,
727 Out: &bytes.Buffer{},
728 UpdateEvery: 1,
729 AutoDetectEvery: 1,
730 FunctionOnly: true,
731 })
732
733 require.NoError(t, job.AutoDetection())
734
735 done := make(chan struct{})
736 go func() {
737 job.Start()
738 close(done)
739 }()
740
741 for i := range 3 {
742 job.Tick(i + 1)
743 time.Sleep(10 * time.Millisecond)
744 }
745 job.Stop()
746
747 select {
748 case <-done:
749 case <-time.After(time.Second):
750 t.Fatal("job did not stop")
751 }
752
753 assert.Equal(t, 0, collectCalls)
754 },
755 },
756 }
757
758 for name, tc := range tests {
759 t.Run(name, tc.run)
760 }
761 }
762
763 func TestJobV2_StartMarksNotRunningBeforeCleanup(t *testing.T) {
764 cleanupStarted := make(chan struct{})
765 cleanupRelease := make(chan struct{})
766 cleanupEntered := make(chan struct{}, 1)
767
768 mod := &mockModuleV2{
769 store: metrix.NewCollectorStore(),
770 template: chartTemplateV2(),
771 cleanupFunc: func(context.Context) {
772 select {
773 case cleanupEntered <- struct{}{}:
774 default:
775 }
776 close(cleanupStarted)
777 <-cleanupRelease
778 },
779 }
780
781 var out bytes.Buffer
782 job := newTestJobV2(mod, &out)
783 require.NoError(t, job.AutoDetection())
784
785 startDone := make(chan struct{})
786 go func() {
787 job.Start()
788 close(startDone)
789 }()
790
791 require.Eventually(t, job.IsRunning, time.Second, 10*time.Millisecond)
792
793 stopDone := make(chan struct{})
794 go func() {
795 job.Stop()
796 close(stopDone)
797 }()
798
799 select {
800 case <-cleanupStarted:
801 case <-time.After(2 * time.Second):
802 t.Fatal("timeout waiting for cleanup to start")
803 }
804
805 assert.False(t, job.IsRunning(), "job must report not running while cleanup is in progress")
806
807 close(cleanupRelease)
808
809 select {
810 case <-stopDone:
811 case <-time.After(2 * time.Second):
812 t.Fatal("timeout waiting for stop to finish")
813 }
814
815 select {
816 case <-startDone:
817 case <-time.After(2 * time.Second):
818 t.Fatal("timeout waiting for start loop to exit")
819 }
820
821 select {
822 case <-cleanupEntered:
823 default:
824 t.Fatal("cleanup function was not entered")
825 }
826 }
827
828 func TestJobV2VnodeEmissionLifecycle(t *testing.T) {
829 store := metrix.NewCollectorStore()
830 current := 1.0
831 mod := &mockModuleV2{
832 store: store,
833 template: chartTemplateV2(),
834 collectFunc: func(context.Context) error {
835 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(current)
836 return nil
837 },
838 }
839
840 var out bytes.Buffer
841 job := newTestJobV2WithVnode(mod, &out, vnodes.VirtualNode{
842 Hostname: "node-host",
843 GUID: "node-guid",
844 Labels: map[string]string{
845 "region": "eu'\n",
846 },
847 })
848 require.NoError(t, job.AutoDetection())
849
850 job.runOnce()
851 wire := out.String()
852 assert.Contains(t, wire, `HOST_DEFINE 'node-guid' 'node-host'
853 HOST_LABEL '_hostname' 'node-host'
854 HOST_LABEL 'region' 'eu '
855 HOST_DEFINE_END
856
857 HOST 'node-guid'
858
859 CHART 'module_job.workers_busy'`)
860 assert.NotContains(t, wire, "HOST ''")
861
862 out.Reset()
863 current = 2
864 job.runOnce()
865 wire = out.String()
866 assert.Contains(t, wire, `HOST 'node-guid'
867
868 BEGIN 'module_job.workers_busy'`)
869 assert.NotContains(t, wire, "HOST_DEFINE 'node-guid' 'node-host'")
870
871 out.Reset()
872 job.UpdateVnode(&vnodes.VirtualNode{
873 Hostname: "node-host-2",
874 GUID: "node-guid-2",
875 })
876 current = 3
877 job.runOnce()
878 wire = out.String()
879 assert.Contains(t, wire, `HOST_DEFINE 'node-guid-2' 'node-host-2'
880 HOST_LABEL '_hostname' 'node-host-2'
881 HOST_DEFINE_END
882
883 HOST 'node-guid-2'
884
885 CHART 'module_job.workers_busy'`)
886 }
887
888 func TestJobV2ModuleOwnedVnodeSameGUIDMetadataRefresh(t *testing.T) {
889 cases := map[string]struct {
890 mutate func(*vnodes.VirtualNode)
891 wantDefine bool
892 wantDefineWire string
893 wantInfo netdataapi.HostInfo
894 }{
895 "unchanged metadata does not redefine": {
896 mutate: func(*vnodes.VirtualNode) {},
897 wantDefine: false,
898 wantInfo: netdataapi.HostInfo{
899 GUID: "node-guid",
900 Hostname: "node-host-a",
901 Labels: map[string]string{
902 "_hostname": "node-host-a",
903 "region": "eu",
904 },
905 },
906 },
907 "hostname change redefines same guid": {
908 mutate: func(vnode *vnodes.VirtualNode) {
909 vnode.Hostname = "node-host-b"
910 },
911 wantDefine: true,
912 wantDefineWire: `HOST_DEFINE 'node-guid' 'node-host-b'
913 HOST_LABEL '_hostname' 'node-host-b'
914 HOST_LABEL 'region' 'eu'
915 HOST_DEFINE_END
916
917 HOST 'node-guid'
918
919 BEGIN 'module_job.workers_busy'`,
920 wantInfo: netdataapi.HostInfo{
921 GUID: "node-guid",
922 Hostname: "node-host-b",
923 Labels: map[string]string{
924 "_hostname": "node-host-b",
925 "region": "eu",
926 },
927 },
928 },
929 "label change redefines same guid": {
930 mutate: func(vnode *vnodes.VirtualNode) {
931 vnode.Labels["region"] = "us"
932 },
933 wantDefine: true,
934 wantDefineWire: `HOST_DEFINE 'node-guid' 'node-host-a'
935 HOST_LABEL '_hostname' 'node-host-a'
936 HOST_LABEL 'region' 'us'
937 HOST_DEFINE_END
938
939 HOST 'node-guid'
940
941 BEGIN 'module_job.workers_busy'`,
942 wantInfo: netdataapi.HostInfo{
943 GUID: "node-guid",
944 Hostname: "node-host-a",
945 Labels: map[string]string{
946 "_hostname": "node-host-a",
947 "region": "us",
948 },
949 },
950 },
951 }
952
953 for name, tc := range cases {
954 t.Run(name, func(t *testing.T) {
955 store := metrix.NewCollectorStore()
956 current := 1.0
957 modVnode := &vnodes.VirtualNode{
958 Hostname: "node-host-a",
959 GUID: "node-guid",
960 Labels: map[string]string{
961 "region": "eu",
962 },
963 }
964 mod := &mockModuleV2{
965 store: store,
966 template: chartTemplateV2(),
967 vnode: modVnode,
968 collectFunc: func(context.Context) error {
969 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(current)
970 return nil
971 },
972 }
973
974 var out bytes.Buffer
975 job := newTestJobV2WithVnode(mod, &out, *modVnode.Copy())
976 require.NoError(t, job.AutoDetection())
977
978 initialInfo, err := chartemit.PrepareHostInfo(netdataapi.HostInfo{
979 GUID: "node-guid",
980 Hostname: "node-host-a",
981 Labels: map[string]string{
982 "region": "eu",
983 },
984 })
985 require.NoError(t, err)
986
987 job.runOnce()
988 require.Equal(t, initialInfo, requireDefaultScopeState(t, job).host.definedInfo)
989
990 out.Reset()
991 tc.mutate(modVnode)
992 current = 2
993
994 job.runOnce()
995 wire := out.String()
996 if tc.wantDefine {
997 assert.Contains(t, wire, tc.wantDefineWire)
998 } else {
999 assert.NotContains(t, wire, `HOST_DEFINE 'node-guid'`)
1000 assert.Contains(t, wire, `HOST 'node-guid'
1001
1002 BEGIN 'module_job.workers_busy'`)
1003 }
1004
1005 expectedInfo, err := chartemit.PrepareHostInfo(tc.wantInfo)
1006 require.NoError(t, err)
1007 assert.Equal(t, expectedInfo, requireDefaultScopeState(t, job).host.definedInfo)
1008 })
1009 }
1010 }
1011
1012 func TestJobV2VnodeRegistryScenarios(t *testing.T) {
1013 cases := map[string]struct {
1014 run func(t *testing.T)
1015 }{
1016 "shared registry suppresses duplicate and updates changed metadata": {
1017 run: func(t *testing.T) {
1018 registry := vnoderegistry.New()
1019 jobAOut := &bytes.Buffer{}
1020 jobBOut := &bytes.Buffer{}
1021
1022 jobA := newRegistryTestJobV2(t, "module_job_a", registry, jobAOut, vnodes.VirtualNode{
1023 Hostname: "node-host-a",
1024 GUID: "node-guid",
1025 Labels: map[string]string{
1026 "region": "eu",
1027 },
1028 })
1029 jobB := newRegistryTestJobV2(t, "module_job_b", registry, jobBOut, vnodes.VirtualNode{
1030 Hostname: "node-host-b",
1031 GUID: "node-guid",
1032 Labels: map[string]string{
1033 "region": "us",
1034 },
1035 })
1036
1037 jobA.runOnce()
1038 assert.Contains(t, jobAOut.String(), `HOST_DEFINE 'node-guid' 'node-host-a'`)
1039 assert.Contains(t, jobAOut.String(), `HOST 'node-guid'`)
1040
1041 jobB.runOnce()
1042 assert.Contains(t, jobBOut.String(), `HOST_DEFINE 'node-guid' 'node-host-b'`)
1043 assert.Contains(t, jobBOut.String(), `HOST 'node-guid'`)
1044
1045 info, ok := registry.Lookup("node-guid")
1046 require.True(t, ok)
1047 assert.Equal(t, "node-host-b", info.Hostname)
1048
1049 jobAOut.Reset()
1050 jobA.runOnce()
1051 assert.Contains(t, jobAOut.String(), `HOST_DEFINE 'node-guid' 'node-host-a'`)
1052 info, ok = registry.Lookup("node-guid")
1053 require.True(t, ok)
1054 assert.Equal(t, "node-host-a", info.Hostname)
1055
1056 assert.Equal(t, []vnoderegistry.Owner{
1057 vnoderegistry.Owner("module_job_a\xffjob\xffnode-guid"),
1058 vnoderegistry.Owner("module_job_b\xffjob\xffnode-guid"),
1059 }, registry.Owners("node-guid"))
1060
1061 jobA.Cleanup()
1062 assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job_b\xffjob\xffnode-guid")}, registry.Owners("node-guid"))
1063 jobB.Cleanup()
1064 assert.Equal(t, 0, registry.Len())
1065 },
1066 },
1067 "rollback on apply failure": {
1068 run: func(t *testing.T) {
1069 registry := vnoderegistry.New()
1070 _, err := registry.Register("other", netdataapi.HostInfo{
1071 GUID: "node-guid",
1072 Hostname: "node-host-a",
1073 })
1074 require.NoError(t, err)
1075
1076 store := metrix.NewCollectorStore()
1077 mod := &mockModuleV2{
1078 store: store,
1079 template: chartTemplateV2(),
1080 collectFunc: func(context.Context) error {
1081 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
1082 return nil
1083 },
1084 }
1085
1086 var out bytes.Buffer
1087 job := NewJobV2(JobV2Config{
1088 PluginName: pluginName,
1089 Name: jobName,
1090 ModuleName: modName,
1091 FullName: strings.Repeat("a", 1200),
1092 Module: mod,
1093 Out: &out,
1094 UpdateEvery: 1,
1095 VnodeRegistry: registry,
1096 Vnode: vnodes.VirtualNode{
1097 Hostname: "node-host-b",
1098 GUID: "node-guid",
1099 },
1100 })
1101 require.NoError(t, job.AutoDetection())
1102
1103 job.runOnce()
1104
1105 assert.Empty(t, out.String())
1106 info, ok := registry.Lookup("node-guid")
1107 require.True(t, ok)
1108 assert.Equal(t, "node-host-a", info.Hostname)
1109 assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("other")}, registry.Owners("node-guid"))
1110 },
1111 },
1112 "rollback on commit failure emits nothing and next cycle recovers": {
1113 run: func(t *testing.T) {
1114 registry := vnoderegistry.New()
1115 store := metrix.NewCollectorStore()
1116 current := 1.0
1117 mod := &mockModuleV2{
1118 store: store,
1119 template: chartTemplateV2(),
1120 collectFunc: func(context.Context) error {
1121 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(current)
1122 return nil
1123 },
1124 }
1125
1126 var out bytes.Buffer
1127 job := NewJobV2(JobV2Config{
1128 PluginName: pluginName,
1129 Name: jobName,
1130 ModuleName: modName,
1131 FullName: modName + "_" + jobName,
1132 Module: mod,
1133 Out: &out,
1134 UpdateEvery: 1,
1135 VnodeRegistry: registry,
1136 Vnode: vnodes.VirtualNode{
1137 Hostname: "node-host",
1138 GUID: "node-guid",
1139 },
1140 })
1141 require.NoError(t, job.AutoDetection())
1142
1143 prepared, ok := job.collectAndEmit(0)
1144 require.True(t, ok)
1145 require.Len(t, prepared.scopes, 1)
1146 require.NotEmpty(t, prepared.scopes[0].output)
1147 assert.NotEmpty(t, registry.Owners("node-guid"))
1148
1149 requireDefaultScopeState(t, job).engine.ResetMaterialized()
1150 require.ErrorIs(t, job.finishPreparedEmission(prepared), chartengine.ErrStalePlanAttempt)
1151 assert.Empty(t, out.String())
1152 assert.Empty(t, registry.Owners("node-guid"))
1153
1154 current = 2
1155 job.runOnce()
1156 assert.Contains(t, out.String(), `HOST_DEFINE 'node-guid' 'node-host'`)
1157 assert.Contains(t, out.String(), "SET 'busy' = 2")
1158 assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffjob\xffnode-guid")}, registry.Owners("node-guid"))
1159 },
1160 },
1161 "guid change releases superseded owner": {
1162 run: func(t *testing.T) {
1163 registry := vnoderegistry.New()
1164 store := metrix.NewCollectorStore()
1165 current := 1.0
1166 mod := &mockModuleV2{
1167 store: store,
1168 template: chartTemplateV2(),
1169 collectFunc: func(context.Context) error {
1170 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(current)
1171 return nil
1172 },
1173 }
1174
1175 var out bytes.Buffer
1176 job := NewJobV2(JobV2Config{
1177 PluginName: pluginName,
1178 Name: jobName,
1179 ModuleName: modName,
1180 FullName: modName + "_" + jobName,
1181 Module: mod,
1182 Out: &out,
1183 UpdateEvery: 1,
1184 VnodeRegistry: registry,
1185 Vnode: vnodes.VirtualNode{
1186 Hostname: "node-host-a",
1187 GUID: "node-guid-a",
1188 },
1189 })
1190 require.NoError(t, job.AutoDetection())
1191
1192 job.runOnce()
1193 assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffjob\xffnode-guid-a")}, registry.Owners("node-guid-a"))
1194
1195 out.Reset()
1196 current = 2
1197 job.UpdateVnode(&vnodes.VirtualNode{
1198 Hostname: "node-host-b",
1199 GUID: "node-guid-b",
1200 })
1201 job.runOnce()
1202
1203 assert.Empty(t, registry.Owners("node-guid-a"))
1204 assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffjob\xffnode-guid-b")}, registry.Owners("node-guid-b"))
1205 },
1206 },
1207 "vnode to global switch releases superseded owner": {
1208 run: func(t *testing.T) {
1209 registry := vnoderegistry.New()
1210 store := metrix.NewCollectorStore()
1211 current := 1.0
1212 mod := &mockModuleV2{
1213 store: store,
1214 template: chartTemplateV2(),
1215 collectFunc: func(context.Context) error {
1216 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(current)
1217 return nil
1218 },
1219 }
1220
1221 var out bytes.Buffer
1222 job := NewJobV2(JobV2Config{
1223 PluginName: pluginName,
1224 Name: jobName,
1225 ModuleName: modName,
1226 FullName: modName + "_" + jobName,
1227 Module: mod,
1228 Out: &out,
1229 UpdateEvery: 1,
1230 VnodeRegistry: registry,
1231 Vnode: vnodes.VirtualNode{
1232 Hostname: "node-host",
1233 GUID: "node-guid",
1234 },
1235 })
1236 require.NoError(t, job.AutoDetection())
1237
1238 job.runOnce()
1239 require.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffjob\xffnode-guid")}, registry.Owners("node-guid"))
1240
1241 out.Reset()
1242 current = 2
1243 job.UpdateVnode(&vnodes.VirtualNode{})
1244 job.runOnce()
1245
1246 assert.Empty(t, registry.Owners("node-guid"))
1247 assert.Contains(t, out.String(), `HOST ''`)
1248 assert.Contains(t, out.String(), "SET 'busy' = 2")
1249 },
1250 },
1251 "cleanup emits obsoletes before releasing owner": {
1252 run: func(t *testing.T) {
1253 registry := vnoderegistry.New()
1254 var out bytes.Buffer
1255 job := newRegistryTestJobV2(t, "module_job", registry, &out, vnodes.VirtualNode{
1256 Hostname: "node-host",
1257 GUID: "node-guid",
1258 })
1259
1260 job.runOnce()
1261 require.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffjob\xffnode-guid")}, registry.Owners("node-guid"))
1262
1263 ownerPresentDuringWrite := false
1264 job.out = writeFunc(func(p []byte) (int, error) {
1265 ownerPresentDuringWrite = assert.Contains(t, registry.Owners("node-guid"), vnoderegistry.Owner("module_job\xffjob\xffnode-guid"))
1266 return len(p), nil
1267 })
1268
1269 job.Cleanup()
1270
1271 assert.True(t, ownerPresentDuringWrite)
1272 assert.Empty(t, registry.Owners("node-guid"))
1273 },
1274 },
1275 "cleanup with obsolete disabled releases owners and clears state": {
1276 run: func(t *testing.T) {
1277 registry := vnoderegistry.New()
1278 var out bytes.Buffer
1279 job := newRegistryTestJobV2(t, "module_job", registry, &out, vnodes.VirtualNode{
1280 Hostname: "node-host",
1281 GUID: "node-guid",
1282 })
1283
1284 job.runOnce()
1285 require.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffjob\xffnode-guid")}, registry.Owners("node-guid"))
1286 require.NotEmpty(t, job.scopeStates)
1287
1288 out.Reset()
1289 collectorapi.ObsoleteCharts(false)
1290 defer collectorapi.ObsoleteCharts(true)
1291 job.Cleanup()
1292
1293 assert.Empty(t, out.String())
1294 assert.Empty(t, registry.Owners("node-guid"))
1295 assert.Empty(t, job.scopeStates)
1296 },
1297 },
1298 "bad hostname aborts cycle without owner leak": {
1299 run: func(t *testing.T) {
1300 registry := vnoderegistry.New()
1301 store := metrix.NewCollectorStore()
1302 mod := &mockModuleV2{
1303 store: store,
1304 template: chartTemplateV2(),
1305 collectFunc: func(context.Context) error {
1306 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
1307 return nil
1308 },
1309 }
1310
1311 var out bytes.Buffer
1312 job := NewJobV2(JobV2Config{
1313 PluginName: pluginName,
1314 Name: jobName,
1315 ModuleName: modName,
1316 FullName: modName + "_" + jobName,
1317 Module: mod,
1318 Out: &out,
1319 UpdateEvery: 1,
1320 VnodeRegistry: registry,
1321 Vnode: vnodes.VirtualNode{
1322 Hostname: "bad\nhost",
1323 GUID: "node-guid",
1324 },
1325 })
1326 require.NoError(t, job.AutoDetection())
1327
1328 job.runOnce()
1329
1330 assert.Empty(t, out.String())
1331 assert.Empty(t, registry.Owners("node-guid"))
1332 },
1333 },
1334 "empty plan does not reserve registry": {
1335 run: func(t *testing.T) {
1336 store := metrix.NewCollectorStore()
1337 registry := vnoderegistry.New()
1338 emitValue := false
1339 mod := &mockModuleV2{
1340 store: store,
1341 template: chartTemplateV2(),
1342 collectFunc: func(context.Context) error {
1343 if emitValue {
1344 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
1345 }
1346 return nil
1347 },
1348 }
1349
1350 var out bytes.Buffer
1351 job := NewJobV2(JobV2Config{
1352 PluginName: pluginName,
1353 Name: jobName,
1354 ModuleName: modName,
1355 FullName: modName + "_" + jobName,
1356 Module: mod,
1357 Out: &out,
1358 UpdateEvery: 1,
1359 VnodeRegistry: registry,
1360 Vnode: vnodes.VirtualNode{
1361 Hostname: "node-host",
1362 GUID: "node-guid",
1363 },
1364 })
1365 require.NoError(t, job.AutoDetection())
1366
1367 job.runOnce()
1368 assert.Equal(t, "", out.String())
1369 assert.Equal(t, 0, registry.Len())
1370
1371 emitValue = true
1372 job.runOnce()
1373 assert.Contains(t, out.String(), `HOST_DEFINE 'node-guid' 'node-host'`)
1374 assert.Equal(t, 1, registry.Len())
1375 },
1376 },
1377 "empty plan does not mark vnode defined": {
1378 run: func(t *testing.T) {
1379 store := metrix.NewCollectorStore()
1380 emitValue := false
1381 mod := &mockModuleV2{
1382 store: store,
1383 template: chartTemplateV2(),
1384 collectFunc: func(context.Context) error {
1385 if emitValue {
1386 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
1387 }
1388 return nil
1389 },
1390 }
1391
1392 var out bytes.Buffer
1393 job := newTestJobV2WithVnode(mod, &out, vnodes.VirtualNode{
1394 Hostname: "node-host",
1395 GUID: "node-guid",
1396 })
1397 require.NoError(t, job.AutoDetection())
1398
1399 job.runOnce()
1400 assert.Equal(t, "", out.String())
1401 assert.Nil(t, job.scopeStates[defaultHostScopeKey])
1402
1403 emitValue = true
1404 job.runOnce()
1405 assert.Contains(t, out.String(), `HOST_DEFINE 'node-guid' 'node-host'`)
1406 assert.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid"}, requireDefaultScopeState(t, job).host.definedHost)
1407 },
1408 },
1409 }
1410
1411 for name, tc := range cases {
1412 t.Run(name, tc.run)
1413 }
1414 }
1415
1416 func TestJobV2HostScopeScenarios(t *testing.T) {
1417 scopeA := metrix.HostScope{ScopeKey: "scope-a", GUID: "guid-a", Hostname: "host-a", Labels: map[string]string{"workload": "a"}}
1418 scopeB := metrix.HostScope{ScopeKey: "scope-b", GUID: "guid-b", Hostname: "host-b", Labels: map[string]string{"workload": "b"}}
1419
1420 cases := map[string]struct {
1421 run func(t *testing.T)
1422 }{
1423 "mixed default and explicit scopes emit deterministic host batches": {
1424 run: func(t *testing.T) {
1425 store := metrix.NewCollectorStore()
1426 mod := &mockModuleV2{
1427 store: store,
1428 template: chartTemplateV2(),
1429 collectFunc: func(context.Context) error {
1430 meter := store.Write().SnapshotMeter("apache")
1431 meter.Gauge("workers_busy").Observe(1)
1432 meter.WithHostScope(scopeB).Gauge("workers_busy").Observe(2)
1433 meter.WithHostScope(scopeA).Gauge("workers_busy").Observe(3)
1434 return nil
1435 },
1436 }
1437
1438 var out bytes.Buffer
1439 registry := vnoderegistry.New()
1440 job := NewJobV2(JobV2Config{
1441 PluginName: pluginName,
1442 Name: jobName,
1443 ModuleName: modName,
1444 FullName: modName + "_" + jobName,
1445 Module: mod,
1446 Out: &out,
1447 UpdateEvery: 1,
1448 VnodeRegistry: registry,
1449 })
1450 require.NoError(t, job.AutoDetection())
1451
1452 job.runOnce()
1453
1454 wire := out.String()
1455 assert.Contains(t, wire, `HOST ''
1456
1457 CHART 'module_job.workers_busy'`)
1458 assert.Contains(t, wire, `HOST_DEFINE 'guid-a' 'host-a'`)
1459 assert.Contains(t, wire, `HOST 'guid-a'
1460
1461 CHART 'module_job.workers_busy'`)
1462 assert.Contains(t, wire, `HOST_DEFINE 'guid-b' 'host-b'`)
1463 assert.Contains(t, wire, `HOST 'guid-b'
1464
1465 CHART 'module_job.workers_busy'`)
1466 assertContainsInOrder(t, wire, "HOST ''", "HOST_DEFINE 'guid-a'", "HOST_DEFINE 'guid-b'")
1467 assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffscope\xffscope-a\xffguid-a")}, registry.Owners("guid-a"))
1468 assert.Equal(t, []vnoderegistry.Owner{vnoderegistry.Owner("module_job\xffscope\xffscope-b\xffguid-b")}, registry.Owners("guid-b"))
1469 },
1470 },
1471 "bad explicit scope does not block default scope": {
1472 run: func(t *testing.T) {
1473 store := metrix.NewCollectorStore()
1474 badScope := metrix.HostScope{ScopeKey: "bad", GUID: "bad-guid", Hostname: "bad\nhost"}
1475 mod := &mockModuleV2{
1476 store: store,
1477 template: chartTemplateV2(),
1478 collectFunc: func(context.Context) error {
1479 meter := store.Write().SnapshotMeter("apache")
1480 meter.Gauge("workers_busy").Observe(1)
1481 meter.WithHostScope(badScope).Gauge("workers_busy").Observe(2)
1482 return nil
1483 },
1484 }
1485
1486 var out bytes.Buffer
1487 registry := vnoderegistry.New()
1488 job := NewJobV2(JobV2Config{
1489 PluginName: pluginName,
1490 Name: jobName,
1491 ModuleName: modName,
1492 FullName: modName + "_" + jobName,
1493 Module: mod,
1494 Out: &out,
1495 UpdateEvery: 1,
1496 VnodeRegistry: registry,
1497 })
1498 require.NoError(t, job.AutoDetection())
1499
1500 job.runOnce()
1501
1502 wire := out.String()
1503 assert.Contains(t, wire, `HOST ''
1504
1505 CHART 'module_job.workers_busy'`)
1506 assert.Contains(t, wire, "SET 'busy' = 1")
1507 assert.NotContains(t, wire, "bad-guid")
1508 assert.Empty(t, registry.Owners("bad-guid"))
1509 assert.Equal(t, int64(0), job.retries.Load())
1510 },
1511 },
1512 "disappeared scope is removed through chartengine lifecycle and releases owner": {
1513 run: func(t *testing.T) {
1514 store := metrix.NewCollectorStore()
1515 emitScope := true
1516 mod := &mockModuleV2{
1517 store: store,
1518 template: chartTemplateV2ExpireAfterOne(),
1519 collectFunc: func(context.Context) error {
1520 if emitScope {
1521 store.Write().SnapshotMeter("apache").WithHostScope(scopeA).Gauge("workers_busy").Observe(7)
1522 }
1523 return nil
1524 },
1525 }
1526
1527 var out bytes.Buffer
1528 registry := vnoderegistry.New()
1529 job := NewJobV2(JobV2Config{
1530 PluginName: pluginName,
1531 Name: jobName,
1532 ModuleName: modName,
1533 FullName: modName + "_" + jobName,
1534 Module: mod,
1535 Out: &out,
1536 UpdateEvery: 1,
1537 VnodeRegistry: registry,
1538 })
1539 require.NoError(t, job.AutoDetection())
1540
1541 job.runOnce()
1542 require.Contains(t, out.String(), `HOST_DEFINE 'guid-a' 'host-a'`)
1543 require.NotEmpty(t, registry.Owners("guid-a"))
1544 require.NotNil(t, job.scopeStates["scope-a"])
1545
1546 out.Reset()
1547 emitScope = false
1548 job.runOnce()
1549
1550 wire := out.String()
1551 assert.Contains(t, wire, `HOST 'guid-a'`)
1552 assert.Contains(t, wire, "obsolete")
1553 assert.Empty(t, registry.Owners("guid-a"))
1554 assert.Nil(t, job.scopeStates["scope-a"])
1555 },
1556 },
1557 "disappeared zero-action scope is removed without registry owner": {
1558 run: func(t *testing.T) {
1559 store := metrix.NewCollectorStore()
1560 emitScope := true
1561 mod := &mockModuleV2{
1562 store: store,
1563 template: chartTemplateV2(),
1564 collectFunc: func(context.Context) error {
1565 if emitScope {
1566 store.Write().SnapshotMeter("apache").WithHostScope(scopeA).Gauge("workers_idle").Observe(7)
1567 }
1568 return nil
1569 },
1570 }
1571
1572 var out bytes.Buffer
1573 registry := vnoderegistry.New()
1574 job := NewJobV2(JobV2Config{
1575 PluginName: pluginName,
1576 Name: jobName,
1577 ModuleName: modName,
1578 FullName: modName + "_" + jobName,
1579 Module: mod,
1580 Out: &out,
1581 UpdateEvery: 1,
1582 VnodeRegistry: registry,
1583 })
1584 require.NoError(t, job.AutoDetection())
1585
1586 job.runOnce()
1587 assert.Empty(t, out.String())
1588 assert.Empty(t, registry.Owners("guid-a"))
1589 require.NotNil(t, job.scopeStates["scope-a"])
1590 assert.Empty(t, job.scopeStates["scope-a"].host.cleanupCharts)
1591
1592 emitScope = false
1593 job.runOnce()
1594
1595 assert.Empty(t, out.String())
1596 assert.Empty(t, registry.Owners("guid-a"))
1597 assert.Nil(t, job.scopeStates["scope-a"])
1598 },
1599 },
1600 "default scope removal runs while explicit scope remains": {
1601 run: func(t *testing.T) {
1602 store := metrix.NewCollectorStore()
1603 emitDefault := true
1604 mod := &mockModuleV2{
1605 store: store,
1606 template: chartTemplateV2ExpireAfterOne(),
1607 collectFunc: func(context.Context) error {
1608 meter := store.Write().SnapshotMeter("apache")
1609 if emitDefault {
1610 meter.Gauge("workers_busy").Observe(1)
1611 }
1612 meter.WithHostScope(scopeA).Gauge("workers_busy").Observe(7)
1613 return nil
1614 },
1615 }
1616
1617 var out bytes.Buffer
1618 job := newTestJobV2(mod, &out)
1619 require.NoError(t, job.AutoDetection())
1620
1621 job.runOnce()
1622 require.NotNil(t, job.scopeStates[defaultHostScopeKey])
1623 require.NotNil(t, job.scopeStates["scope-a"])
1624
1625 out.Reset()
1626 emitDefault = false
1627 job.runOnce()
1628
1629 wire := out.String()
1630 assert.Contains(t, wire, `HOST ''`)
1631 assert.Contains(t, wire, "obsolete")
1632 assert.Nil(t, job.scopeStates[defaultHostScopeKey])
1633 assert.NotNil(t, job.scopeStates["scope-a"])
1634 },
1635 },
1636 "per-scope commit failure does not block peer scope": {
1637 run: func(t *testing.T) {
1638 store := metrix.NewCollectorStore()
1639 mod := &mockModuleV2{
1640 store: store,
1641 template: chartTemplateV2(),
1642 collectFunc: func(context.Context) error {
1643 meter := store.Write().SnapshotMeter("apache")
1644 meter.Gauge("workers_busy").Observe(1)
1645 meter.WithHostScope(scopeA).Gauge("workers_busy").Observe(7)
1646 return nil
1647 },
1648 }
1649
1650 var out bytes.Buffer
1651 registry := vnoderegistry.New()
1652 job := NewJobV2(JobV2Config{
1653 PluginName: pluginName,
1654 Name: jobName,
1655 ModuleName: modName,
1656 FullName: modName + "_" + jobName,
1657 Module: mod,
1658 Out: &out,
1659 UpdateEvery: 1,
1660 VnodeRegistry: registry,
1661 })
1662 require.NoError(t, job.AutoDetection())
1663
1664 prepared, ok := job.collectAndEmit(0)
1665 require.True(t, ok)
1666 require.Len(t, prepared.scopes, 2)
1667 for _, scope := range prepared.scopes {
1668 if scope.scope.scopeKey == "scope-a" {
1669 scope.scope.engine.ResetMaterialized()
1670 }
1671 }
1672
1673 require.NoError(t, job.finishPreparedEmission(prepared))
1674
1675 wire := out.String()
1676 assert.Contains(t, wire, `HOST ''`)
1677 assert.Contains(t, wire, "SET 'busy' = 1")
1678 assert.NotContains(t, wire, "guid-a")
1679 assert.Empty(t, registry.Owners("guid-a"))
1680 assert.NotNil(t, job.scopeStates[defaultHostScopeKey])
1681 },
1682 },
1683 "cleanup apply failure on one scope does not block peer cleanup": {
1684 run: func(t *testing.T) {
1685 store := metrix.NewCollectorStore()
1686 mod := &mockModuleV2{store: store, template: chartTemplateV2()}
1687 var out bytes.Buffer
1688 job := newTestJobV2(mod, &out)
1689 require.NoError(t, job.AutoDetection())
1690
1691 okMeta := chartengine.ChartMeta{
1692 Title: "OK",
1693 Family: "Workers",
1694 Context: "workers_ok",
1695 Units: "workers",
1696 Type: chartengine.ChartTypeLine,
1697 Priority: chartengine.Priority,
1698 }
1699 badMeta := okMeta
1700 badMeta.Title = "Bad"
1701 job.scopeStates = map[string]*jobV2ScopeState{
1702 defaultHostScopeKey: {
1703 scopeKey: defaultHostScopeKey,
1704 host: jobV2HostState{
1705 cleanupOwner: jobV2HostRef{kind: jobV2HostGlobal},
1706 cleanupCharts: map[string]chartengine.ChartMeta{
1707 "workers_ok": okMeta,
1708 },
1709 },
1710 },
1711 "bad": {
1712 scopeKey: "bad",
1713 scope: scopeA,
1714 host: jobV2HostState{
1715 cleanupOwner: jobV2HostRef{kind: jobV2HostGlobal},
1716 cleanupCharts: map[string]chartengine.ChartMeta{
1717 strings.Repeat("x", 1300): badMeta,
1718 },
1719 },
1720 },
1721 }
1722
1723 job.Cleanup()
1724
1725 assert.Contains(t, out.String(), "workers_ok")
1726 assert.Contains(t, out.String(), "obsolete")
1727 assert.Empty(t, job.scopeStates)
1728 },
1729 },
1730 "panic after scoped runtime samples resets aggregator and in-flight scope": {
1731 run: func(t *testing.T) {
1732 store := metrix.NewCollectorStore()
1733 mod := &mockModuleV2{
1734 store: store,
1735 template: chartTemplateV2(),
1736 collectFunc: func(context.Context) error {
1737 meter := store.Write().SnapshotMeter("apache")
1738 meter.Gauge("workers_busy").Observe(1)
1739 meter.WithHostScope(scopeA).Gauge("workers_busy").Observe(7)
1740 return nil
1741 },
1742 }
1743
1744 var out bytes.Buffer
1745 registry := vnoderegistry.New()
1746 job := NewJobV2(JobV2Config{
1747 PluginName: pluginName,
1748 Name: jobName,
1749 ModuleName: modName,
1750 FullName: modName + "_" + jobName,
1751 Module: mod,
1752 Out: &out,
1753 UpdateEvery: 1,
1754 VnodeRegistry: registry,
1755 })
1756 require.NoError(t, job.AutoDetection())
1757 job.api = netdataapi.New(writeFunc(func(p []byte) (int, error) {
1758 if bytes.Contains(p, []byte("HOST_DEFINE 'guid-a'")) {
1759 panic("boom")
1760 }
1761 return job.buf.Write(p)
1762 }))
1763
1764 job.runOnce()
1765
1766 assert.True(t, job.Panicked())
1767 assert.Empty(t, out.String())
1768 assert.Empty(t, registry.Owners(scopeA.GUID))
1769 value, ok := job.runtimeStore.Read(metrix.ReadRaw()).Value("netdata.go.plugin.framework.chartengine.build_success_total", nil)
1770 if ok {
1771 assert.Zero(t, value)
1772 }
1773
1774 job.api = netdataapi.New(job.buf)
1775 out.Reset()
1776 job.runOnce()
1777
1778 assert.False(t, job.Panicked())
1779 assert.Contains(t, out.String(), `HOST_DEFINE 'guid-a' 'host-a'`)
1780 assert.NotEmpty(t, registry.Owners(scopeA.GUID))
1781 },
1782 },
1783 }
1784
1785 for name, tc := range cases {
1786 t.Run(name, tc.run)
1787 }
1788 }
1789
1790 func assertContainsInOrder(t *testing.T, s string, parts ...string) {
1791 t.Helper()
1792 offset := 0
1793 for _, part := range parts {
1794 idx := strings.Index(s[offset:], part)
1795 require.NotEqualf(t, -1, idx, "expected %q after offset %d", part, offset)
1796 offset += idx + len(part)
1797 }
1798 }
1799
1800 func TestJobV2CleanupUsesLastSuccessfulHostAfterFailedHostSwitch(t *testing.T) {
1801 store := metrix.NewCollectorStore()
1802 current := 1.0
1803 failCollect := false
1804 mod := &mockModuleV2{
1805 store: store,
1806 template: chartTemplateV2(),
1807 collectFunc: func(context.Context) error {
1808 if failCollect {
1809 return errors.New("collect failed")
1810 }
1811 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(current)
1812 return nil
1813 },
1814 }
1815
1816 var out bytes.Buffer
1817 job := newTestJobV2WithVnode(mod, &out, vnodes.VirtualNode{
1818 Hostname: "node-host-a",
1819 GUID: "node-guid-a",
1820 })
1821 require.NoError(t, job.AutoDetection())
1822
1823 job.runOnce()
1824 out.Reset()
1825
1826 failCollect = true
1827 job.UpdateVnode(&vnodes.VirtualNode{
1828 Hostname: "node-host-b",
1829 GUID: "node-guid-b",
1830 })
1831 job.runOnce()
1832 assert.Equal(t, "", out.String())
1833
1834 job.Cleanup()
1835
1836 wire := out.String()
1837 assert.Contains(t, wire, fmt.Sprintf(`HOST 'node-guid-a'
1838
1839 CHART 'module_job.workers_busy' '' 'Workers Busy' 'workers' 'Workers' 'workers_busy' 'line' '%d' '1' 'obsolete' 'plugin' 'module'`, chartengine.Priority))
1840 assert.NotContains(t, wire, "HOST 'node-guid-b'")
1841 assert.Empty(t, job.scopeStates)
1842 }
1843
1844 func TestJobV2EmptyHostSwitchDoesNotKeepReloadingEngine(t *testing.T) {
1845 store := metrix.NewCollectorStore()
1846 emitValue := true
1847 mod := &mockModuleV2{
1848 store: store,
1849 template: chartTemplateV2(),
1850 collectFunc: func(context.Context) error {
1851 if emitValue {
1852 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
1853 }
1854 return nil
1855 },
1856 }
1857
1858 var out bytes.Buffer
1859 job := newTestJobV2WithVnode(mod, &out, vnodes.VirtualNode{
1860 Hostname: "node-host-a",
1861 GUID: "node-guid-a",
1862 })
1863 require.NoError(t, job.AutoDetection())
1864 require.Equal(t, 1, mod.templateCalls)
1865
1866 job.runOnce()
1867 require.Equal(t, 1, mod.templateCalls)
1868 require.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-a"}, requireDefaultScopeState(t, job).host.engineHost)
1869 require.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-a"}, requireDefaultScopeState(t, job).host.cleanupOwner)
1870
1871 out.Reset()
1872 emitValue = false
1873 job.UpdateVnode(&vnodes.VirtualNode{
1874 Hostname: "node-host-b",
1875 GUID: "node-guid-b",
1876 })
1877 job.runOnce()
1878 assert.Equal(t, "", out.String())
1879 require.Equal(t, 1, mod.templateCalls)
1880 require.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-b"}, requireDefaultScopeState(t, job).host.engineHost)
1881 require.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-a"}, requireDefaultScopeState(t, job).host.cleanupOwner)
1882
1883 out.Reset()
1884 job.runOnce()
1885 assert.Equal(t, "", out.String())
1886 assert.Equal(t, 1, mod.templateCalls)
1887 assert.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-b"}, requireDefaultScopeState(t, job).host.engineHost)
1888 assert.Equal(t, jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid-a"}, requireDefaultScopeState(t, job).host.cleanupOwner)
1889 }
1890
1891 func TestJobV2CleanupDoesNotSuppressGlobalCleanupForDifferentStaleVnode(t *testing.T) {
1892 store := metrix.NewCollectorStore()
1893 failCollect := false
1894 mod := &mockModuleV2{
1895 store: store,
1896 template: chartTemplateV2(),
1897 collectFunc: func(context.Context) error {
1898 if failCollect {
1899 return errors.New("collect failed")
1900 }
1901 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
1902 return nil
1903 },
1904 }
1905
1906 var out bytes.Buffer
1907 job := newTestJobV2(mod, &out)
1908 require.NoError(t, job.AutoDetection())
1909
1910 job.runOnce()
1911 out.Reset()
1912
1913 failCollect = true
1914 job.UpdateVnode(&vnodes.VirtualNode{
1915 Hostname: "node-host-b",
1916 GUID: "node-guid-b",
1917 Labels: map[string]string{
1918 "_node_stale_after_seconds": "60",
1919 },
1920 })
1921 job.runOnce()
1922 assert.Equal(t, "", out.String())
1923
1924 job.Cleanup()
1925
1926 wire := out.String()
1927 assert.Contains(t, wire, fmt.Sprintf(`HOST ''
1928
1929 CHART 'module_job.workers_busy' '' 'Workers Busy' 'workers' 'Workers' 'workers_busy' 'line' '%d' '1' 'obsolete' 'plugin' 'module'`, chartengine.Priority))
1930 assert.NotContains(t, wire, "HOST 'node-guid-b'")
1931 }
1932
1933 func TestJobV2CleanupUsesPreModuleCleanupSnapshotForStaleSuppression(t *testing.T) {
1934 store := metrix.NewCollectorStore()
1935 modVnode := &vnodes.VirtualNode{
1936 Hostname: "node-host-a",
1937 GUID: "node-guid-a",
1938 }
1939 mod := &mockModuleV2{
1940 store: store,
1941 template: chartTemplateV2(),
1942 vnode: modVnode,
1943 collectFunc: func(context.Context) error {
1944 store.Write().SnapshotMeter("apache").Gauge("workers_busy").Observe(1)
1945 return nil
1946 },
1947 }
1948 mod.cleanupFunc = func(context.Context) {
1949 *modVnode = vnodes.VirtualNode{
1950 Hostname: "node-host-b",
1951 GUID: "node-guid-b",
1952 }
1953 }
1954
1955 var out bytes.Buffer
1956 job := newTestJobV2WithVnode(mod, &out, *modVnode.Copy())
1957 require.NoError(t, job.AutoDetection())
1958
1959 job.runOnce()
1960 out.Reset()
1961
1962 modVnode.Labels = map[string]string{
1963 "_node_stale_after_seconds": "60",
1964 }
1965
1966 job.Cleanup()
1967
1968 assert.Equal(t, "", out.String())
1969 assert.True(t, mod.cleaned)
1970 }
1971
1972 func TestJobV2CleanupDoesNotSuppressExplicitScopeForStaleJobVnode(t *testing.T) {
1973 mod := &mockModuleV2{
1974 store: metrix.NewCollectorStore(),
1975 template: chartTemplateV2(),
1976 }
1977
1978 var out bytes.Buffer
1979 job := newTestJobV2WithVnode(mod, &out, vnodes.VirtualNode{
1980 Hostname: "node-host",
1981 GUID: "node-guid",
1982 Labels: map[string]string{
1983 "_node_stale_after_seconds": "60",
1984 },
1985 })
1986 require.NoError(t, job.AutoDetection())
1987
1988 job.scopeStates = map[string]*jobV2ScopeState{
1989 "scope-a": {
1990 scopeKey: "scope-a",
1991 scope: metrix.HostScope{
1992 ScopeKey: "scope-a",
1993 GUID: "node-guid",
1994 Hostname: "scoped-host",
1995 },
1996 host: jobV2HostState{
1997 cleanupOwner: jobV2HostRef{kind: jobV2HostVnode, guid: "node-guid"},
1998 cleanupCharts: map[string]chartengine.ChartMeta{
1999 "workers_busy": {
2000 Title: "Workers Busy",
2001 Family: "Workers",
2002 Context: "workers_busy",
2003 Units: "workers",
2004 Type: chartengine.ChartTypeLine,
2005 Priority: chartengine.Priority,
2006 },
2007 },
2008 },
2009 },
2010 }
2011
2012 job.Cleanup()
2013
2014 assert.Contains(t, out.String(), `HOST 'node-guid'`)
2015 assert.Contains(t, out.String(), "obsolete")
2016 }
2017
2018 func TestJobV2CleanupNoSuccessfulEmissionsIsNoOp(t *testing.T) {
2019 mod := &mockModuleV2{
2020 store: metrix.NewCollectorStore(),
2021 template: chartTemplateV2(),
2022 }
2023
2024 var out bytes.Buffer
2025 job := newTestJobV2(mod, &out)
2026 require.NoError(t, job.AutoDetection())
2027
2028 job.Cleanup()
2029
2030 assert.Equal(t, "", out.String())
2031 assert.True(t, mod.cleaned)
2032 }
2033
2034 func TestJobV2CleanupTrackerUsesEffectiveEmittedChartSet(t *testing.T) {
2035 meta := chartengine.ChartMeta{
2036 Title: "Workers Busy",
2037 Family: "Workers",
2038 Context: "workers_busy",
2039 Units: "workers",
2040 Type: chartengine.ChartTypeLine,
2041 }
2042
2043 job := &JobV2{scopeStates: map[string]*jobV2ScopeState{
2044 defaultHostScopeKey: {scopeKey: defaultHostScopeKey},
2045 }}
2046 state := requireDefaultScopeState(t, job)
2047 decision := jobV2EmissionDecision{targetHost: jobV2HostRef{kind: jobV2HostGlobal}}
2048 state.host.commitSuccessfulEmission(chartengine.Plan{
2049 Actions: []chartengine.EngineAction{
2050 chartengine.CreateDimensionAction{
2051 ChartID: "workers_busy",
2052 ChartMeta: meta,
2053 Name: "busy",
2054 },
2055 },
2056 }, decision)
2057
2058 require.Len(t, state.host.cleanupCharts, 1)
2059 assert.Equal(t, meta, state.host.cleanupCharts["workers_busy"])
2060 assert.Equal(t, jobV2HostRef{kind: jobV2HostGlobal}, state.host.cleanupOwner)
2061
2062 state.host.commitSuccessfulEmission(chartengine.Plan{
2063 Actions: []chartengine.EngineAction{
2064 chartengine.RemoveChartAction{
2065 ChartID: "workers_busy",
2066 Meta: meta,
2067 },
2068 },
2069 }, decision)
2070
2071 assert.Empty(t, state.host.cleanupCharts)
2072 }
2073
2074 func TestJobV2StopBeforeStartDoesNotBlock(t *testing.T) {
2075 job := NewJobV2(JobV2Config{
2076 PluginName: pluginName,
2077 Name: jobName,
2078 ModuleName: modName,
2079 FullName: modName + "_" + jobName,
2080 Out: &bytes.Buffer{},
2081 UpdateEvery: 1,
2082 AutoDetectEvery: 1,
2083 })
2084
2085 done := make(chan struct{})
2086 go func() {
2087 job.Stop()
2088 close(done)
2089 }()
2090
2091 select {
2092 case <-done:
2093 case <-time.After(200 * time.Millisecond):
2094 t.Fatal("stop blocked before start")
2095 }
2096 }