Skip to content

Commit 3f5e7ad

Browse files
committed
feat(stovepipe): observe queue health from a periodic metrics stage
Add a periodicmetrics queue stage that emits the age of a queue's last-known-green commit as a gauge. It is the one stage no other stage feeds: the deployment publishes a PeriodicMetrics message on a schedule, because the health it reports degrades while the pipeline is idle and so cannot be observed from pipeline activity alone. A failed observation acks and is counted with the step that failed; only a message violating the payload contract is rejected, so a persistently unresolvable queue cannot fill the dead-letter queue at the publishing rate. record no longer reports, which takes a source-control call off the delivery path of the stage that owns durable validation state.
1 parent 3b789c8 commit 3f5e7ad

18 files changed

Lines changed: 723 additions & 59 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ local-stovepipe-stop: ## Stop the Stovepipe service
369369

370370
mocks: ## Generate mock files using mockgen
371371
@echo "Generating mocks..."
372-
@$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/consumergate/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/scorer/... ./submitqueue/extension/conflict/... ./submitqueue/extension/speculation/... ./submitqueue/extension/validator/... ./platform/consumer/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/... ./stovepipe/extension/observability/...
372+
@$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/consumergate/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/scorer/... ./submitqueue/extension/conflict/... ./submitqueue/extension/speculation/... ./submitqueue/extension/validator/... ./platform/consumer/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/...
373373
@echo "Mocks generated successfully!"
374374

375375
proto: ## Generate protobuf files from .proto definitions

service/stovepipe/server/BUILD.bazel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ go_library(
2121
"//stovepipe/controller/build:go_default_library",
2222
"//stovepipe/controller/buildsignal:go_default_library",
2323
"//stovepipe/controller/dlq:go_default_library",
24+
"//stovepipe/controller/periodicmetrics:go_default_library",
2425
"//stovepipe/controller/process:go_default_library",
2526
"//stovepipe/controller/record:go_default_library",
2627
"//stovepipe/core/messagequeue:go_default_library",
2728
"//stovepipe/extension/buildrunner:go_default_library",
2829
"//stovepipe/extension/buildrunner/fake:go_default_library",
29-
"//stovepipe/extension/observability/lastgreen:go_default_library",
3030
"//stovepipe/extension/queueconfig/default:go_default_library",
3131
"//stovepipe/extension/sourcecontrol:go_default_library",
3232
"//stovepipe/extension/sourcecontrol/fake:go_default_library",

service/stovepipe/server/main.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,12 @@ import (
4343
"github.com/uber/submitqueue/stovepipe/controller/build"
4444
"github.com/uber/submitqueue/stovepipe/controller/buildsignal"
4545
"github.com/uber/submitqueue/stovepipe/controller/dlq"
46+
"github.com/uber/submitqueue/stovepipe/controller/periodicmetrics"
4647
"github.com/uber/submitqueue/stovepipe/controller/process"
4748
"github.com/uber/submitqueue/stovepipe/controller/record"
4849
stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue"
4950
"github.com/uber/submitqueue/stovepipe/extension/buildrunner"
5051
buildrunnerfake "github.com/uber/submitqueue/stovepipe/extension/buildrunner/fake"
51-
"github.com/uber/submitqueue/stovepipe/extension/observability/lastgreen"
5252
queueconfigdefault "github.com/uber/submitqueue/stovepipe/extension/queueconfig/default"
5353
"github.com/uber/submitqueue/stovepipe/extension/sourcecontrol"
5454
sourcecontrolfake "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol/fake"
@@ -427,13 +427,25 @@ func registerPrimaryControllers(
427427
}
428428
count++
429429

430-
reporters := lastgreen.NewFactory(scope, store, scf)
431-
recordController := record.NewController(logger, scope, store, reporters, stovepipemq.TopicKeyRecord, "stovepipe-record")
430+
recordController := record.NewController(logger, scope, store, stovepipemq.TopicKeyRecord, "stovepipe-record")
432431
if err := c.Register(recordController); err != nil {
433432
return count, fmt.Errorf("failed to register record controller: %w", err)
434433
}
435434
count++
436435

436+
periodicMetricsController := periodicmetrics.NewController(
437+
logger,
438+
scope,
439+
store,
440+
scf,
441+
stovepipemq.TopicKeyPeriodicMetrics,
442+
"stovepipe-periodicmetrics",
443+
)
444+
if err := c.Register(periodicMetricsController); err != nil {
445+
return count, fmt.Errorf("failed to register periodic metrics controller: %w", err)
446+
}
447+
count++
448+
437449
return count, nil
438450
}
439451

@@ -469,6 +481,9 @@ func registerDLQControllers(
469481
// topic and the buildsignal consumer subscribes to it, and also republishes to itself while
470482
// polling. buildsignal publishes to the record topic once a build reaches a terminal status,
471483
// and the record consumer subscribes to it.
484+
//
485+
// The periodicmetrics topic is the exception: no stage publishes to it. The deployment does,
486+
// on whatever schedule it wants queue-health observations.
472487
func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRegistry, error) {
473488
return consumer.NewTopicRegistry([]consumer.TopicConfig{
474489
{
@@ -503,6 +518,14 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe
503518
subscriberName, "stovepipe-record",
504519
),
505520
},
521+
{
522+
Key: stovepipemq.TopicKeyPeriodicMetrics,
523+
Name: "periodicmetrics",
524+
Queue: q,
525+
Subscription: extqueue.DefaultSubscriptionConfig(
526+
subscriberName, "stovepipe-periodicmetrics",
527+
),
528+
},
506529
{
507530
Key: dlq.TopicKey(stovepipemq.TopicKeyProcess),
508531
Name: "process_dlq",
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["periodicmetrics.go"],
6+
importpath = "github.com/uber/submitqueue/stovepipe/controller/periodicmetrics",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//platform/consumer:go_default_library",
10+
"//platform/metrics:go_default_library",
11+
"//stovepipe/core/messagequeue:go_default_library",
12+
"//stovepipe/extension/sourcecontrol:go_default_library",
13+
"//stovepipe/extension/storage:go_default_library",
14+
"@com_github_uber_go_tally//:go_default_library",
15+
"@org_uber_go_zap//:go_default_library",
16+
],
17+
)
18+
19+
go_test(
20+
name = "go_default_test",
21+
srcs = ["periodicmetrics_test.go"],
22+
embed = [":go_default_library"],
23+
deps = [
24+
"//platform/base/messagequeue:go_default_library",
25+
"//platform/consumer/mock:go_default_library",
26+
"//stovepipe/core/messagequeue:go_default_library",
27+
"//stovepipe/entity:go_default_library",
28+
"//stovepipe/extension/sourcecontrol:go_default_library",
29+
"//stovepipe/extension/sourcecontrol/mock:go_default_library",
30+
"//stovepipe/extension/storage:go_default_library",
31+
"//stovepipe/extension/storage/mock:go_default_library",
32+
"@com_github_stretchr_testify//assert:go_default_library",
33+
"@com_github_stretchr_testify//require:go_default_library",
34+
"@com_github_uber_go_tally//:go_default_library",
35+
"@org_uber_go_mock//gomock:go_default_library",
36+
"@org_uber_go_zap//:go_default_library",
37+
],
38+
)
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// Package periodicmetrics holds the periodic-metrics queue controller. It consumes
16+
// PeriodicMetrics messages (a queue name) and emits metrics describing that queue's
17+
// current health, sampled from state the pipeline already persists.
18+
//
19+
// It is the one stage no other stage feeds: the deployment publishes to this topic
20+
// on a schedule. That is deliberate rather than incidental. The health reported here
21+
// degrades while nothing happens — a queue whose last-known-green commit stops
22+
// advancing ages silently — so an observation triggered by pipeline activity would go
23+
// quiet in exactly the outage worth alerting on. Being driven by a clock instead of by
24+
// work gives the observation a cadence that holds while the pipeline is idle.
25+
//
26+
// The stage advances no entity and publishes nothing onward. It reads through the
27+
// storage and source-control extensions and writes only metrics.
28+
package periodicmetrics
29+
30+
import (
31+
"context"
32+
"fmt"
33+
"time"
34+
35+
"github.com/uber-go/tally"
36+
"github.com/uber/submitqueue/platform/consumer"
37+
"github.com/uber/submitqueue/platform/metrics"
38+
stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue"
39+
"github.com/uber/submitqueue/stovepipe/extension/sourcecontrol"
40+
"github.com/uber/submitqueue/stovepipe/extension/storage"
41+
"go.uber.org/zap"
42+
)
43+
44+
// Controller consumes PeriodicMetrics messages and observes the named queue.
45+
// Implements consumer.Controller.
46+
type Controller struct {
47+
logger *zap.SugaredLogger
48+
metricsScope tally.Scope
49+
stores storage.Factory
50+
sourceControls sourcecontrol.Factory
51+
topicKey consumer.TopicKey
52+
consumerGroup string
53+
}
54+
55+
// Verify Controller implements consumer.Controller interface at compile time.
56+
var _ consumer.Controller = (*Controller)(nil)
57+
58+
const (
59+
// _opName is the metric operation name for this stage's own handling counters.
60+
_opName = "periodicmetrics"
61+
62+
// _opLastGreen is the metric operation name for the last-known-green
63+
// observation. It is named for what is measured rather than for this stage, so
64+
// the series an operator alerts on does not move if the stage does.
65+
_opLastGreen = "last_green"
66+
)
67+
68+
// NewController creates a new periodic metrics controller.
69+
func NewController(
70+
logger *zap.SugaredLogger,
71+
scope tally.Scope,
72+
stores storage.Factory,
73+
sourceControls sourcecontrol.Factory,
74+
topicKey consumer.TopicKey,
75+
consumerGroup string,
76+
) *Controller {
77+
return &Controller{
78+
logger: logger.Named("periodicmetrics_controller"),
79+
metricsScope: scope.SubScope("periodicmetrics_controller"),
80+
stores: stores,
81+
sourceControls: sourceControls,
82+
topicKey: topicKey,
83+
consumerGroup: consumerGroup,
84+
}
85+
}
86+
87+
// Process observes the queue named in the delivery. Returns nil to ack (success) or
88+
// an error to nack (retry) / reject (DLQ).
89+
//
90+
// Only a message that violates the payload contract is rejected; a failed observation
91+
// acks. Nothing downstream depends on this stage, so an error would buy nothing but
92+
// retries of a sample whose moment has passed — and since the schedule keeps producing
93+
// messages, a persistently failing observation would fill the dead-letter queue at the
94+
// publishing rate. Every reason an observation cannot be made is counted with the step
95+
// that failed instead, which is where a reporting fault belongs.
96+
func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) error {
97+
msg := delivery.Message()
98+
99+
req := &stovepipemq.PeriodicMetrics{}
100+
if err := stovepipemq.Unmarshal(msg.Payload, req); err != nil {
101+
metrics.NamedCounter(c.metricsScope, _opName, "deserialize_errors", 1)
102+
// Non-retryable: a malformed message will never succeed regardless of retries.
103+
return fmt.Errorf("failed to deserialize periodic metrics request: %w", err)
104+
}
105+
106+
queue := req.GetQueueName()
107+
if queue == "" {
108+
metrics.NamedCounter(c.metricsScope, _opName, "missing_queue", 1)
109+
// Non-retryable: the queue to observe is the whole payload.
110+
return fmt.Errorf("periodic metrics request has no queue name")
111+
}
112+
113+
c.reportLastGreenAge(ctx, queue)
114+
115+
metrics.NamedCounter(c.metricsScope, _opName, "observed", 1, metrics.NewTag("queue", queue))
116+
return nil
117+
}
118+
119+
// reportLastGreenAge updates the gauge holding the current age of the queue's
120+
// last-known-green commit. A gauge rather than a histogram because the answer is the
121+
// latest observation, not a distribution: how stale the bookmark is *now*.
122+
//
123+
// Callers gate deployments on that commit, so its age is the staleness of the newest
124+
// thing they are allowed to ship: a queue whose green bookmark stopped advancing looks
125+
// healthy from the pipeline's perspective — nothing is failing — while the answer it
126+
// serves silently ages.
127+
func (c *Controller) reportLastGreenAge(ctx context.Context, queue string) {
128+
queueTag := metrics.NewTag("queue", queue)
129+
130+
store, err := c.stores.For(storage.Config{QueueName: queue})
131+
if err != nil {
132+
c.ageError(queueTag, "resolve_storage", queue, err)
133+
return
134+
}
135+
136+
queueRow, err := store.GetQueueStore().Get(ctx, queue)
137+
if err != nil {
138+
c.ageError(queueTag, "get_queue", queue, err)
139+
return
140+
}
141+
142+
// A queue that has never gone green has no age to report. Emitting zero
143+
// would read as "green as of right now", the opposite of the truth.
144+
if queueRow.LastGreenURI == "" {
145+
metrics.NamedCounter(c.metricsScope, _opLastGreen, "age_missing", 1, queueTag)
146+
return
147+
}
148+
149+
sourceControl, err := c.sourceControls.For(sourcecontrol.Config{QueueName: queue})
150+
if err != nil {
151+
c.ageError(queueTag, "resolve_source_control", queue, err)
152+
return
153+
}
154+
155+
info, err := sourceControl.ChangeInfo(ctx, queueRow.LastGreenURI)
156+
if err != nil || info.CreatedAt.IsZero() {
157+
c.ageError(queueTag, "get_change_info", queue, err)
158+
return
159+
}
160+
161+
// A commit dated in the future means the provider's clock disagrees with
162+
// ours; a negative age would corrupt the series rather than describe it.
163+
age := time.Since(info.CreatedAt)
164+
if age < 0 {
165+
c.ageError(queueTag, "future_change", queue, nil)
166+
return
167+
}
168+
169+
metrics.NamedGauge(c.metricsScope, _opLastGreen, "age_seconds", age.Seconds(), queueTag)
170+
}
171+
172+
// ageError counts an observation that could not be made, tagged with the step that
173+
// failed so a silent gauge can be told apart from a broken dependency.
174+
func (c *Controller) ageError(queueTag metrics.Tag, step, queue string, err error) {
175+
metrics.NamedCounter(c.metricsScope, _opLastGreen, "age_errors", 1, queueTag, metrics.NewTag("step", step))
176+
c.logger.Errorw("failed to observe last green age", "queue", queue, "step", step, "error", err)
177+
}
178+
179+
// Name returns the controller name for logging and metrics.
180+
func (c *Controller) Name() string {
181+
return "periodicmetrics"
182+
}
183+
184+
// TopicKey returns the topic key this controller subscribes to.
185+
func (c *Controller) TopicKey() consumer.TopicKey {
186+
return c.topicKey
187+
}
188+
189+
// ConsumerGroup returns the consumer group for offset tracking.
190+
func (c *Controller) ConsumerGroup() string {
191+
return c.consumerGroup
192+
}

0 commit comments

Comments
 (0)