Skip to content

Commit fe99c7a

Browse files
diylivldmonster
andauthored
[addon-operator] add queue head info metric and critical flag to module info (#771)
Signed-off-by: diyliv <onlogn081@gmail.com> Co-authored-by: Pavel Okhlopkov <36456348+ldmonster@users.noreply.github.com>
1 parent e082f7a commit fe99c7a

4 files changed

Lines changed: 348 additions & 6 deletions

File tree

pkg/addon-operator/bootstrap.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/flant/addon-operator/pkg/kube_config_manager/backend"
1313
"github.com/flant/addon-operator/pkg/metrics"
1414
"github.com/flant/addon-operator/pkg/module_manager"
15+
"github.com/flant/addon-operator/pkg/task"
1516
taskservice "github.com/flant/addon-operator/pkg/task/service"
1617
"github.com/flant/shell-operator/pkg/debug"
1718
shell_operator "github.com/flant/shell-operator/pkg/shell-operator"
@@ -77,7 +78,14 @@ func (op *AddonOperator) Assemble(debugServer *debug.Server) error {
7778

7879
// Start background updaters for metrics
7980
metrics.StartLiveTicksUpdater(op.engine.MetricStorage)
80-
metrics.StartTasksQueueLengthUpdater(op.engine.MetricStorage, op.engine.TaskQueues)
81+
metrics.StartTasksQueueLengthUpdater(op.engine.MetricStorage, op.engine.TaskQueues, func(metadata interface{}) (string, string) {
82+
hm, ok := metadata.(task.HookMetadata)
83+
if !ok {
84+
return "", ""
85+
}
86+
87+
return hm.ModuleName, hm.HookName
88+
})
8189

8290
// Register debug HTTP endpoints to inspect internal state
8391
op.engine.RegisterDebugQueueRoutes(debugServer)

pkg/metrics/metrics.go

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ var (
115115
TaskWaitInQueueSecondsTotal = "{PREFIX}task_wait_in_queue_seconds_total"
116116
// TasksQueueLength shows current length of task queues
117117
TasksQueueLength = "{PREFIX}tasks_queue_length"
118+
// TasksQueueHeadInfo shows the head element of each non-empty task queue (module, task_type, hook)
119+
TasksQueueHeadInfo = "{PREFIX}tasks_queue_head_info"
118120

119121
// ============================================================================
120122
// Live Ticks Metrics
@@ -123,6 +125,9 @@ var (
123125
LiveTicks = "{PREFIX}live_ticks"
124126
)
125127

128+
// tasksQueueHeadInfoMetricGroup is the metric group name (without prefix) for tasks_queue_head_info.
129+
const tasksQueueHeadInfoMetricGroup = "tasks_queue_head_info"
130+
126131
// Standard histogram buckets for timing metrics (1ms to 10s)
127132
var buckets_1msTo10s = []float64{
128133
0.0,
@@ -203,6 +208,7 @@ func InitMetrics(prefix string) {
203208
// ============================================================================
204209
TaskWaitInQueueSecondsTotal = ReplacePrefix(TaskWaitInQueueSecondsTotal, prefix)
205210
TasksQueueLength = ReplacePrefix(TasksQueueLength, prefix)
211+
TasksQueueHeadInfo = ReplacePrefix(TasksQueueHeadInfo, prefix)
206212

207213
// ============================================================================
208214
// Live Ticks Metrics
@@ -592,17 +598,25 @@ func StartLiveTicksUpdater(metricStorage metricsstorage.Storage) {
592598
}
593599

594600
// StartTasksQueueLengthUpdater starts a goroutine that periodically updates
595-
// the tasks_queue_length metric every 5 seconds.
596-
// This metric shows the number of pending tasks in each queue, which can be useful
597-
// for monitoring system load and potential backlog issues.
598-
func StartTasksQueueLengthUpdater(metricStorage metricsstorage.Storage, tqs *queue.TaskQueueSet) {
601+
// the tasks_queue_length and tasks_queue_head_info metrics every 5 seconds.
602+
// These metrics show the number of pending tasks and the head element info
603+
// (module, task_type, hook) of each non-empty queue.
604+
// headInfoExtractor is a callback that extracts (module, hook) from a task's raw metadata.
605+
func StartTasksQueueLengthUpdater(metricStorage metricsstorage.Storage, tqs *queue.TaskQueueSet, headInfoExtractor func(metadata interface{}) (module, hook string)) {
599606
// Register the tasks queue length gauge
600607
_, _ = metricStorage.RegisterGauge(
601608
TasksQueueLength,
602609
[]string{pkg.MetricKeyQueue},
603610
options.WithHelp("Gauge showing the length of the task queue"),
604611
)
605612

613+
// Register the tasks queue head info gauge
614+
_, _ = metricStorage.RegisterGauge(
615+
TasksQueueHeadInfo,
616+
[]string{pkg.MetricKeyQueue, "module", "task_type", "hook"},
617+
options.WithHelp("Head info of each non-empty task queue (module, task_type, hook)"),
618+
)
619+
606620
// Start the updater goroutine
607621
go func() {
608622
for {
@@ -612,7 +626,44 @@ func StartTasksQueueLengthUpdater(metricStorage metricsstorage.Storage, tqs *que
612626
metricStorage.GaugeSet(TasksQueueLength, queueLen, map[string]string{pkg.MetricKeyQueue: queue.Name})
613627
})
614628

629+
// Publish head_info for each non-empty queue, expiring old series first.
630+
updateTasksQueueHeadInfo(tqs, metricStorage, headInfoExtractor)
631+
615632
time.Sleep(5 * time.Second)
616633
}
617634
}()
618635
}
636+
637+
// updateTasksQueueHeadInfo publishes head_info metrics for all non-empty queues,
638+
// properly expiring old series before republishing to prevent phantom series
639+
// from persisting after a queue head changes.
640+
func updateTasksQueueHeadInfo(tqs *queue.TaskQueueSet, metricStorage metricsstorage.Storage, headInfoExtractor func(metadata interface{}) (module, hook string)) {
641+
metricStorage.Grouped().ExpireGroupMetricByName(tasksQueueHeadInfoMetricGroup, TasksQueueHeadInfo)
642+
643+
tqs.IterateSnapshot(context.TODO(), func(_ context.Context, q *queue.TaskQueue) {
644+
t := q.GetFirst()
645+
if t == nil {
646+
return
647+
}
648+
649+
module, hook := headInfoExtractor(t.GetMetadata())
650+
651+
// Normalize ParallelModuleRun synthetic module names:
652+
// "Parallel run for a, b, c" -> "" to avoid false joins with deckhouse_mm_module_info.
653+
if strings.HasPrefix(module, "Parallel run for ") {
654+
module = ""
655+
}
656+
657+
metricStorage.Grouped().GaugeSet(
658+
tasksQueueHeadInfoMetricGroup,
659+
TasksQueueHeadInfo,
660+
1,
661+
map[string]string{
662+
pkg.MetricKeyQueue: q.Name,
663+
"module": module,
664+
"task_type": string(t.GetType()),
665+
"hook": hook,
666+
},
667+
)
668+
})
669+
}

pkg/metrics/metrics_test.go

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
package metrics
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
metricsstorage "github.com/deckhouse/deckhouse/pkg/metrics-storage"
8+
"github.com/prometheus/client_golang/prometheus"
9+
dto "github.com/prometheus/client_model/go"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/flant/addon-operator/pkg"
14+
sh_task "github.com/flant/shell-operator/pkg/task"
15+
"github.com/flant/shell-operator/pkg/task/queue"
16+
)
17+
18+
// newTestTask creates a BaseTask with given type, moduleName, and hookName labels.
19+
// Note: metadata is stored as opaque interface{}; extraction is done by the caller's callback.
20+
func newTestTask(taskType sh_task.TaskType) sh_task.Task {
21+
return sh_task.NewTask(taskType)
22+
}
23+
24+
// setupTestStorage creates a MetricStorage backed by a fresh prometheus registry
25+
// and registers the tasks_queue_head_info gauge.
26+
func setupTestStorage(t *testing.T) (metricsstorage.Storage, prometheus.Gatherer) {
27+
t.Helper()
28+
registry := prometheus.NewRegistry()
29+
storage := metricsstorage.NewMetricStorage(
30+
metricsstorage.WithRegistry(registry),
31+
)
32+
_, err := storage.RegisterGauge(
33+
TasksQueueHeadInfo,
34+
[]string{pkg.MetricKeyQueue, "module", "task_type", "hook"},
35+
)
36+
require.NoError(t, err)
37+
return storage, registry
38+
}
39+
40+
// setupTestQueueSet creates a TaskQueueSet with a single "main" queue and one task in it.
41+
// Returns the queue set and the main queue for adding more tasks.
42+
func setupTestQueueSet(t *testing.T, storage metricsstorage.Storage) (*queue.TaskQueueSet, *queue.TaskQueue) {
43+
t.Helper()
44+
tqs := queue.NewTaskQueueSet()
45+
// Provide a context for queue internals (WithContext in queue options).
46+
tqs.WithContext(context.Background())
47+
// Provide storage so queue internals don't panic on nil metric storage access.
48+
tqs.WithMetricStorage(storage)
49+
tqs.NewNamedQueue("main", nil)
50+
mainQ := tqs.GetByName("main")
51+
require.NotNil(t, mainQ)
52+
return tqs, mainQ
53+
}
54+
55+
// findHeadInfoMetric returns the tasks_queue_head_info metric family from gathered metrics.
56+
func findHeadInfoMetric(t *testing.T, gatherer prometheus.Gatherer) []*dto.Metric {
57+
t.Helper()
58+
mfs, err := gatherer.Gather()
59+
require.NoError(t, err)
60+
for _, mf := range mfs {
61+
if mf.GetName() == TasksQueueHeadInfo {
62+
return mf.GetMetric()
63+
}
64+
}
65+
return nil
66+
}
67+
68+
// labelMap converts *dto.LabelPair slice to a map for easy assertions.
69+
func labelMap(m *dto.Metric) map[string]string {
70+
result := make(map[string]string)
71+
for _, lp := range m.GetLabel() {
72+
result[lp.GetName()] = lp.GetValue()
73+
}
74+
return result
75+
}
76+
77+
// staticHeadExtractor returns a head info extractor that always returns the given values.
78+
func staticHeadExtractor(module, hook string) func(interface{}) (string, string) {
79+
return func(_ interface{}) (string, string) {
80+
return module, hook
81+
}
82+
}
83+
84+
func TestUpdateTasksQueueHeadInfo_EmptyQueue_NoSeries(t *testing.T) {
85+
storage, gatherer := setupTestStorage(t)
86+
tqs, _ := setupTestQueueSet(t, storage)
87+
88+
extractor := staticHeadExtractor("module", "hook")
89+
updateTasksQueueHeadInfo(tqs, storage, extractor)
90+
91+
metrics := findHeadInfoMetric(t, gatherer)
92+
assert.Nil(t, metrics, "empty queue should produce no head_info series")
93+
}
94+
95+
func TestUpdateTasksQueueHeadInfo_NonEmptyQueue_OneSeries(t *testing.T) {
96+
storage, gatherer := setupTestStorage(t)
97+
tqs, mainQ := setupTestQueueSet(t, storage)
98+
99+
task := newTestTask(sh_task.TaskType("ModuleRun"))
100+
mainQ.AddLast(task)
101+
102+
extractor := staticHeadExtractor("test-module", "test-hook")
103+
updateTasksQueueHeadInfo(tqs, storage, extractor)
104+
105+
metrics := findHeadInfoMetric(t, gatherer)
106+
require.Len(t, metrics, 1)
107+
labels := labelMap(metrics[0])
108+
assert.Equal(t, "main", labels[pkg.MetricKeyQueue])
109+
assert.Equal(t, "test-module", labels["module"])
110+
assert.Equal(t, "ModuleRun", labels["task_type"])
111+
assert.Equal(t, "test-hook", labels["hook"])
112+
assert.Equal(t, float64(1), metrics[0].GetGauge().GetValue())
113+
}
114+
115+
func TestUpdateTasksQueueHeadInfo_ParallelModuleRunNormalization(t *testing.T) {
116+
storage, gatherer := setupTestStorage(t)
117+
tqs, mainQ := setupTestQueueSet(t, storage)
118+
119+
task := newTestTask(sh_task.TaskType("ParallelModuleRun"))
120+
mainQ.AddLast(task)
121+
122+
// Simulate ParallelModuleRun synthetic module name
123+
extractor := staticHeadExtractor("Parallel run for module-a, module-b", "")
124+
updateTasksQueueHeadInfo(tqs, storage, extractor)
125+
126+
metrics := findHeadInfoMetric(t, gatherer)
127+
require.Len(t, metrics, 1)
128+
labels := labelMap(metrics[0])
129+
assert.Equal(t, "", labels["module"], "ParallelModuleRun synthetic name should be normalized to empty string")
130+
assert.Equal(t, "ParallelModuleRun", labels["task_type"])
131+
}
132+
133+
func TestUpdateTasksQueueHeadInfo_GlobalTask_EmptyModule(t *testing.T) {
134+
storage, gatherer := setupTestStorage(t)
135+
tqs, mainQ := setupTestQueueSet(t, storage)
136+
137+
task := newTestTask(sh_task.TaskType("ConvergeModules"))
138+
mainQ.AddLast(task)
139+
140+
// Global tasks have empty ModuleName
141+
extractor := staticHeadExtractor("", "")
142+
updateTasksQueueHeadInfo(tqs, storage, extractor)
143+
144+
metrics := findHeadInfoMetric(t, gatherer)
145+
require.Len(t, metrics, 1)
146+
labels := labelMap(metrics[0])
147+
assert.Equal(t, "", labels["module"], "global task should have empty module")
148+
assert.Equal(t, "", labels["hook"], "global task should have empty hook")
149+
assert.Equal(t, "ConvergeModules", labels["task_type"])
150+
}
151+
152+
func TestUpdateTasksQueueHeadInfo_HeadChange_OldSeriesExpired(t *testing.T) {
153+
storage, gatherer := setupTestStorage(t)
154+
tqs, mainQ := setupTestQueueSet(t, storage)
155+
156+
// First: add task with "module-a"
157+
taskA := newTestTask(sh_task.TaskType("ModuleRun"))
158+
mainQ.AddLast(taskA)
159+
160+
extractorA := staticHeadExtractor("module-a", "hook-a")
161+
updateTasksQueueHeadInfo(tqs, storage, extractorA)
162+
163+
metrics := findHeadInfoMetric(t, gatherer)
164+
require.Len(t, metrics, 1, "first head should produce one series")
165+
assert.Equal(t, "module-a", labelMap(metrics[0])["module"])
166+
167+
// Second: remove first task and add new task with "module-b"
168+
mainQ.RemoveFirst()
169+
taskB := newTestTask(sh_task.TaskType("ModuleRun"))
170+
mainQ.AddLast(taskB)
171+
172+
extractorB := staticHeadExtractor("module-b", "hook-b")
173+
updateTasksQueueHeadInfo(tqs, storage, extractorB)
174+
175+
metrics = findHeadInfoMetric(t, gatherer)
176+
require.Len(t, metrics, 1, "after head change, only one active series should remain")
177+
labels := labelMap(metrics[0])
178+
assert.Equal(t, "module-b", labels["module"], "new head should be module-b")
179+
assert.Equal(t, "hook-b", labels["hook"], "new head should be hook-b")
180+
// Verify old module-a is gone
181+
assert.NotEqual(t, "module-a", labels["module"], "old head series should be expired")
182+
}
183+
184+
func TestUpdateTasksQueueHeadInfo_MultipleQueues_CorrectLabels(t *testing.T) {
185+
storage, gatherer := setupTestStorage(t)
186+
tqs := queue.NewTaskQueueSet()
187+
tqs.WithContext(context.Background())
188+
tqs.WithMetricStorage(storage)
189+
tqs.NewNamedQueue("main", nil)
190+
tqs.NewNamedQueue("secondary", nil)
191+
192+
mainQ := tqs.GetByName("main")
193+
secQ := tqs.GetByName("secondary")
194+
195+
mainQ.AddLast(newTestTask(sh_task.TaskType("ModuleRun")))
196+
secQ.AddLast(newTestTask(sh_task.TaskType("ModuleHookRun")))
197+
198+
// Use different extractors per queue — simulate real behavior via type switch
199+
// In real code, the extractor is shared. We test with a shared static extractor
200+
// that can differentiate based on task type.
201+
sharedExtractor := staticHeadExtractor("shared-module", "shared-hook")
202+
updateTasksQueueHeadInfo(tqs, storage, sharedExtractor)
203+
204+
metrics := findHeadInfoMetric(t, gatherer)
205+
require.Len(t, metrics, 2, "two non-empty queues should produce two series")
206+
207+
// Collect queue names from metrics
208+
queueNames := make(map[string]bool)
209+
for _, m := range metrics {
210+
queueNames[labelMap(m)[pkg.MetricKeyQueue]] = true
211+
}
212+
assert.True(t, queueNames["main"], "main queue should be present")
213+
assert.True(t, queueNames["secondary"], "secondary queue should be present")
214+
}
215+
216+
func TestUpdateTasksQueueHeadInfo_QueueEmptyAfterHead_Removed(t *testing.T) {
217+
storage, gatherer := setupTestStorage(t)
218+
tqs, mainQ := setupTestQueueSet(t, storage)
219+
220+
// First: add a task, run updater
221+
mainQ.AddLast(newTestTask(sh_task.TaskType("ModuleRun")))
222+
extractor := staticHeadExtractor("some-module", "some-hook")
223+
updateTasksQueueHeadInfo(tqs, storage, extractor)
224+
225+
metrics := findHeadInfoMetric(t, gatherer)
226+
require.Len(t, metrics, 1, "should have one series initially")
227+
228+
// Remove the task, making queue empty
229+
mainQ.RemoveFirst()
230+
updateTasksQueueHeadInfo(tqs, storage, extractor)
231+
232+
metrics = findHeadInfoMetric(t, gatherer)
233+
assert.Nil(t, metrics, "after queue becomes empty, all head_info series should be expired")
234+
}
235+
236+
func TestTasksQueueHeadInfo_ConstantPrefix(t *testing.T) {
237+
// In tests, ReplacePrefix is not called (that happens at app bootstrap).
238+
// The variables contain "{PREFIX}..." as placeholders.
239+
// Verify the variables exist and are initialized.
240+
assert.NotEmpty(t, TasksQueueLength)
241+
assert.NotEmpty(t, TasksQueueHeadInfo)
242+
assert.Contains(t, TasksQueueHeadInfo, "{PREFIX}", "should contain placeholder before ReplacePrefix")
243+
}
244+
245+
func TestTasksQueueLength_ConstantPrefix(t *testing.T) {
246+
// TasksQueueLength is initialized to "{PREFIX}..." in package init.
247+
// After ReplacePrefix is called (which happens at app startup), it gets the real prefix.
248+
// In tests, ReplacePrefix is typically called by the app bootstrap.
249+
// We just verify the variable exists and is a non-empty string.
250+
assert.NotEmpty(t, TasksQueueLength)
251+
}
252+
253+
// Ensure the package init doesn't panic due to uninitialized variables.
254+
func TestPackageInit_NoPanic(t *testing.T) {
255+
assert.NotEmpty(t, TasksQueueHeadInfo)
256+
assert.NotEmpty(t, ModuleInfoMetricName)
257+
}
258+
259+
// Test that ExpireGroupMetricByName handles unregistered metrics gracefully.
260+
func TestExpireGroupMetricByName_UnregisteredMetric_NoPanic(t *testing.T) {
261+
registry := prometheus.NewRegistry()
262+
storage := metricsstorage.NewMetricStorage(
263+
metricsstorage.WithRegistry(registry),
264+
)
265+
// Calling ExpireGroupMetricByName on an unregistered metric should not panic.
266+
assert.NotPanics(t, func() {
267+
storage.Grouped().ExpireGroupMetricByName("nonexistent", "nonexistent_metric")
268+
})
269+
}

0 commit comments

Comments
 (0)