1818// releases the queue's build slot, projects the outcome onto the request, and
1919// publishes the request id to record. See
2020// doc/rfc/stovepipe/steps/buildsignal.md.
21+ //
22+ // It reads source control for one thing only: a failure is the moment a break
23+ // becomes known, so the stage that records the failure is the only place the time
24+ // it went undetected can be measured.
2125package buildsignal
2226
2327import (
2428 "context"
2529 "errors"
2630 "fmt"
31+ "time"
2732
2833 "github.com/uber-go/tally"
2934 entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
@@ -33,6 +38,7 @@ import (
3338 stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue"
3439 "github.com/uber/submitqueue/stovepipe/entity"
3540 "github.com/uber/submitqueue/stovepipe/extension/buildrunner"
41+ "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol"
3642 "github.com/uber/submitqueue/stovepipe/extension/storage"
3743 "go.uber.org/zap"
3844)
@@ -57,39 +63,49 @@ var (
5763// the request, and publishes the request id to record. Implements
5864// consumer.Controller.
5965type Controller struct {
60- logger * zap.SugaredLogger
61- metricsScope tally.Scope
62- stores storage.Factory
63- buildRunners buildrunner.Factory
64- registry consumer.TopicRegistry
65- topicKey consumer.TopicKey
66- consumerGroup string
66+ logger * zap.SugaredLogger
67+ metricsScope tally.Scope
68+ stores storage.Factory
69+ buildRunners buildrunner.Factory
70+ sourceControls sourcecontrol.Factory
71+ registry consumer.TopicRegistry
72+ topicKey consumer.TopicKey
73+ consumerGroup string
6774}
6875
6976// Verify Controller implements consumer.Controller interface at compile time.
7077var _ consumer.Controller = (* Controller )(nil )
7178
72- // _opName is the metric operation name shared by every emit in this file.
73- const _opName = "buildsignal"
79+ const (
80+ // _opName is the metric operation name for this stage's own handling counters.
81+ _opName = "buildsignal"
82+
83+ // _opBuildFailure is the metric operation name for the failure-detection
84+ // observation. It is named for what is measured rather than for this stage, so
85+ // the series an operator alerts on does not move if the stage does.
86+ _opBuildFailure = "build_failure"
87+ )
7488
7589// NewController creates a new buildsignal controller.
7690func NewController (
7791 logger * zap.SugaredLogger ,
7892 scope tally.Scope ,
7993 stores storage.Factory ,
8094 buildRunners buildrunner.Factory ,
95+ sourceControls sourcecontrol.Factory ,
8196 registry consumer.TopicRegistry ,
8297 topicKey consumer.TopicKey ,
8398 consumerGroup string ,
8499) * Controller {
85100 return & Controller {
86- logger : logger .Named ("buildsignal_controller" ),
87- metricsScope : scope .SubScope ("buildsignal_controller" ),
88- stores : stores ,
89- buildRunners : buildRunners ,
90- registry : registry ,
91- topicKey : topicKey ,
92- consumerGroup : consumerGroup ,
101+ logger : logger .Named ("buildsignal_controller" ),
102+ metricsScope : scope .SubScope ("buildsignal_controller" ),
103+ stores : stores ,
104+ buildRunners : buildRunners ,
105+ sourceControls : sourceControls ,
106+ registry : registry ,
107+ topicKey : topicKey ,
108+ consumerGroup : consumerGroup ,
93109 }
94110}
95111
@@ -270,10 +286,70 @@ func (c *Controller) markOutcome(ctx context.Context, store storage.Storage, req
270286 metrics .NamedCounter (c .metricsScope , _opName , "outcomes" , 1 ,
271287 metrics .NewTag ("state" , string (state )),
272288 )
289+ // Reported from the write that records the failure, so only the writer of an
290+ // outcome reports it: a redelivery returns above without a second sample.
291+ if state == entity .RequestStateFailed {
292+ c .reportDetectionLatency (ctx , * request )
293+ }
273294 return nil
274295 }
275296}
276297
298+ // reportDetectionLatency records how long the break this build failed on went
299+ // undetected, measured from the commit timestamp of the base it validated against. A
300+ // histogram rather than a gauge because the distribution over failures is the point:
301+ // how long a break typically survives, not how long the last one did.
302+ //
303+ // The observation is best-effort — every way it can fail is counted and swallowed,
304+ // never affecting the outcome that was just recorded. It costs a source-control call
305+ // on the delivery path, which this measurement cannot trade away the way a
306+ // current-state signal can: there is no later moment to sample an elapsed time from,
307+ // because it is only meaningful against the failure that just became known. The call
308+ // is confined to failures and made after the outcome is durable.
309+ func (c * Controller ) reportDetectionLatency (ctx context.Context , request entity.Request ) {
310+ queueTag := metrics .NewTag ("queue" , request .Queue )
311+ strategyTag := metrics .NewTag ("strategy" , string (request .BuildStrategy ))
312+
313+ // Only a strategy that validates a delta pins a base commit, so a full build has
314+ // no baseline to measure from. Its failures are counted rather than timed: absent
315+ // here is the ordinary case, not a fault.
316+ if request .BaseURI == "" {
317+ metrics .NamedCounter (c .metricsScope , _opBuildFailure , "detection_missing" , 1 , queueTag , strategyTag )
318+ return
319+ }
320+
321+ sourceControl , err := c .sourceControls .For (sourcecontrol.Config {QueueName : request .Queue })
322+ if err != nil {
323+ c .detectionError (queueTag , "resolve_source_control" , request .Queue , err )
324+ return
325+ }
326+
327+ info , err := sourceControl .ChangeInfo (ctx , request .BaseURI )
328+ if err != nil || info .CreatedAt .IsZero () {
329+ c .detectionError (queueTag , "get_change_info" , request .Queue , err )
330+ return
331+ }
332+
333+ // A commit dated in the future means the provider's clock disagrees with ours;
334+ // a negative latency would corrupt the distribution rather than describe it.
335+ latency := time .Since (info .CreatedAt )
336+ if latency < 0 {
337+ c .detectionError (queueTag , "future_change" , request .Queue , nil )
338+ return
339+ }
340+
341+ metrics .NamedHistogram (c .metricsScope , _opBuildFailure , "time_to_detection" , metrics .ChangeAgeBuckets ,
342+ queueTag , strategyTag ,
343+ ).RecordDuration (latency )
344+ }
345+
346+ // detectionError counts an observation that could not be made, tagged with the step
347+ // that failed so an unmeasurable failure can be told apart from a broken dependency.
348+ func (c * Controller ) detectionError (queueTag metrics.Tag , step , queue string , err error ) {
349+ metrics .NamedCounter (c .metricsScope , _opBuildFailure , "detection_errors" , 1 , queueTag , metrics .NewTag ("step" , step ))
350+ c .logger .Errorw ("failed to observe build failure detection latency" , "queue" , queue , "step" , step , "error" , err )
351+ }
352+
277353// releaseBuildSlot CAS-decrements the queue's in_flight_count, reopening the process
278354// concurrency gate now that this request's build is over. It decrements relatively
279355// (preserving concurrent updates), clamps at zero, and retries on version conflicts.
0 commit comments