Skip to content

Commit 72579db

Browse files
committed
feat(stovepipe): add the record controller, advancing a queue's last-green bookmark on a successful build
1 parent a125bc5 commit 72579db

5 files changed

Lines changed: 560 additions & 2 deletions

File tree

service/stovepipe/server/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ go_library(
2020
"//stovepipe/controller/buildsignal:go_default_library",
2121
"//stovepipe/controller/dlq:go_default_library",
2222
"//stovepipe/controller/process:go_default_library",
23+
"//stovepipe/controller/record:go_default_library",
2324
"//stovepipe/core/messagequeue:go_default_library",
2425
"//stovepipe/extension/buildrunner:go_default_library",
2526
"//stovepipe/extension/buildrunner/fake:go_default_library",

service/stovepipe/server/main.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import (
4242
"github.com/uber/submitqueue/stovepipe/controller/buildsignal"
4343
"github.com/uber/submitqueue/stovepipe/controller/dlq"
4444
"github.com/uber/submitqueue/stovepipe/controller/process"
45+
"github.com/uber/submitqueue/stovepipe/controller/record"
4546
stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue"
4647
"github.com/uber/submitqueue/stovepipe/extension/buildrunner"
4748
buildrunnerfake "github.com/uber/submitqueue/stovepipe/extension/buildrunner/fake"
@@ -393,6 +394,12 @@ func registerPrimaryControllers(
393394
}
394395
count++
395396

397+
recordController := record.NewController(logger, scope, store, stovepipemq.TopicKeyRecord, "stovepipe-record")
398+
if err := c.Register(recordController); err != nil {
399+
return count, fmt.Errorf("failed to register record controller: %w", err)
400+
}
401+
count++
402+
396403
return count, nil
397404
}
398405

@@ -420,8 +427,8 @@ func registerDLQControllers(
420427
// publishes to the process topic and the process consumer subscribes to it; process publishes
421428
// to the build topic and the build consumer subscribes to it; build publishes to the buildsignal
422429
// topic and the buildsignal consumer subscribes to it, and also republishes to itself while
423-
// polling. buildsignal publishes to the record topic once a build reaches a terminal status; it
424-
// has no Subscription yet since no consumer for it exists until the record stage lands.
430+
// polling. buildsignal publishes to the record topic once a build reaches a terminal status,
431+
// and the record consumer subscribes to it.
425432
func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRegistry, error) {
426433
return consumer.NewTopicRegistry([]consumer.TopicConfig{
427434
{
@@ -452,6 +459,9 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe
452459
Key: stovepipemq.TopicKeyRecord,
453460
Name: "record",
454461
Queue: q,
462+
Subscription: extqueue.DefaultSubscriptionConfig(
463+
subscriberName, "stovepipe-record",
464+
),
455465
},
456466
{
457467
Key: dlq.TopicKey(stovepipemq.TopicKeyProcess),
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["record.go"],
6+
importpath = "github.com/uber/submitqueue/stovepipe/controller/record",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//platform/consumer:go_default_library",
10+
"//platform/metrics:go_default_library",
11+
"//stovepipe/core/loader:go_default_library",
12+
"//stovepipe/core/messagequeue:go_default_library",
13+
"//stovepipe/entity:go_default_library",
14+
"//stovepipe/extension/storage:go_default_library",
15+
"@com_github_uber_go_tally//:go_default_library",
16+
"@org_uber_go_zap//:go_default_library",
17+
],
18+
)
19+
20+
go_test(
21+
name = "go_default_test",
22+
srcs = ["record_test.go"],
23+
embed = [":go_default_library"],
24+
deps = [
25+
"//platform/base/messagequeue:go_default_library",
26+
"//platform/consumer/mock:go_default_library",
27+
"//stovepipe/core/messagequeue:go_default_library",
28+
"//stovepipe/entity:go_default_library",
29+
"//stovepipe/extension/storage:go_default_library",
30+
"//stovepipe/extension/storage/mock:go_default_library",
31+
"@com_github_stretchr_testify//assert:go_default_library",
32+
"@com_github_stretchr_testify//require:go_default_library",
33+
"@com_github_uber_go_tally//:go_default_library",
34+
"@org_uber_go_mock//gomock:go_default_library",
35+
"@org_uber_go_zap//:go_default_library",
36+
],
37+
)
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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 record holds the record-stage queue controller. It consumes Record
16+
// messages (a request id) published by buildsignal once a build reaches a
17+
// terminal status, and turns that outcome into durable validation state. See
18+
// doc/rfc/stovepipe/steps/record.md.
19+
//
20+
// Phase 1 records only the queue's last-green bookmark, which process reads to
21+
// choose an incremental build baseline. Validation facts and downstream hooks
22+
// are not implemented yet.
23+
package record
24+
25+
import (
26+
"context"
27+
"errors"
28+
"fmt"
29+
30+
"github.com/uber-go/tally"
31+
"github.com/uber/submitqueue/platform/consumer"
32+
"github.com/uber/submitqueue/platform/metrics"
33+
"github.com/uber/submitqueue/stovepipe/core/loader"
34+
stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue"
35+
"github.com/uber/submitqueue/stovepipe/entity"
36+
"github.com/uber/submitqueue/stovepipe/extension/storage"
37+
"go.uber.org/zap"
38+
)
39+
40+
// Controller consumes Record messages and advances the queue's last-green
41+
// bookmark when the request's build succeeded. Implements consumer.Controller.
42+
type Controller struct {
43+
logger *zap.SugaredLogger
44+
metricsScope tally.Scope
45+
store storage.Storage
46+
topicKey consumer.TopicKey
47+
consumerGroup string
48+
}
49+
50+
// Verify Controller implements consumer.Controller interface at compile time.
51+
var _ consumer.Controller = (*Controller)(nil)
52+
53+
// _opName is the metric operation name shared by every emit in this file.
54+
const _opName = "record"
55+
56+
// NewController creates a new record controller.
57+
func NewController(
58+
logger *zap.SugaredLogger,
59+
scope tally.Scope,
60+
store storage.Storage,
61+
topicKey consumer.TopicKey,
62+
consumerGroup string,
63+
) *Controller {
64+
return &Controller{
65+
logger: logger.Named("record_controller"),
66+
metricsScope: scope.SubScope("record_controller"),
67+
store: store,
68+
topicKey: topicKey,
69+
consumerGroup: consumerGroup,
70+
}
71+
}
72+
73+
// Process loads the request referenced by the delivery and, when its build
74+
// succeeded, advances the queue's last-green bookmark. Returns nil to ack
75+
// (success) or an error to nack (retry) / reject (DLQ).
76+
//
77+
// buildsignal stamps the outcome on the request before publishing here, so a
78+
// request without a build outcome is a producer invariant violation rather
79+
// than a state this stage waits for.
80+
func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) error {
81+
msg := delivery.Message()
82+
83+
rec := &stovepipemq.Record{}
84+
if err := stovepipemq.Unmarshal(msg.Payload, rec); err != nil {
85+
metrics.NamedCounter(c.metricsScope, _opName, "deserialize_errors", 1)
86+
// Non-retryable: a malformed message will never succeed regardless of retries.
87+
return fmt.Errorf("failed to deserialize record: %w", err)
88+
}
89+
90+
request, err := c.loadRequest(ctx, rec.Id)
91+
if err != nil {
92+
metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1)
93+
return err
94+
}
95+
96+
switch request.State {
97+
case entity.RequestStateSucceeded:
98+
if err := c.advanceLastGreen(ctx, request); err != nil {
99+
metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1)
100+
return err
101+
}
102+
return nil
103+
104+
case entity.RequestStateFailed, entity.RequestStateCancelled:
105+
// A verdict, but not a green one: nothing to record in phase 1. A
106+
// cancelled build decided nothing about the commit at all.
107+
metrics.NamedCounter(c.metricsScope, _opName, "not_green", 1)
108+
return nil
109+
110+
case entity.RequestStateSuperseded:
111+
// Terminal without a build outcome. buildsignal never publishes for a
112+
// superseded request, so this is unreachable in practice.
113+
metrics.NamedCounter(c.metricsScope, _opName, "superseded", 1)
114+
return nil
115+
116+
default:
117+
// Non-retryable: buildsignal publishes only after committing the
118+
// outcome, so a non-terminal request here is a broken invariant that
119+
// retrying cannot fix.
120+
metrics.NamedCounter(c.metricsScope, _opName, "invariant_errors", 1)
121+
return fmt.Errorf("request %s reached record in non-terminal state %q", request.ID, request.State)
122+
}
123+
}
124+
125+
// advanceLastGreen points the queue's bookmark at request, retrying on version
126+
// conflicts. The bookmark only moves forward: a candidate whose id is not newer
127+
// than the stored one is skipped without a write, which also makes a redelivery
128+
// of the same request a no-op.
129+
//
130+
// Greenness comes from request.State rather than a persisted validation fact,
131+
// which is what record.md specifies. The two agree — a fact's degree is derived
132+
// from the same immutable state — and this reads the fact once the fact store
133+
// lands (uber/submitqueue#394, phase 2).
134+
func (c *Controller) advanceLastGreen(ctx context.Context, request entity.Request) error {
135+
queueStore := c.store.GetQueueStore()
136+
137+
for {
138+
queueRow, err := queueStore.Get(ctx, request.Queue)
139+
if err != nil {
140+
return fmt.Errorf("failed to load queue %s to advance last green: %w", request.Queue, err)
141+
}
142+
143+
newer, err := isNewerRequest(request.Queue, request.ID, queueRow.LastGreenRequestID)
144+
if err != nil {
145+
// Non-retryable: re-parsing the same ids cannot start succeeding.
146+
return err
147+
}
148+
if !newer {
149+
return nil
150+
}
151+
152+
updated := queueRow
153+
updated.LastGreenURI = request.URI
154+
updated.LastGreenRequestID = request.ID
155+
newVersion := queueRow.Version + 1
156+
if err := queueStore.Update(ctx, updated, queueRow.Version, newVersion); err != nil {
157+
if errors.Is(err, storage.ErrVersionMismatch) {
158+
continue
159+
}
160+
return fmt.Errorf("failed to advance last green for queue %s: %w", request.Queue, err)
161+
}
162+
163+
metrics.NamedCounter(c.metricsScope, _opName, "last_green_advanced", 1)
164+
c.logger.Infow("advanced last green bookmark",
165+
"queue", request.Queue,
166+
"request_id", request.ID,
167+
"last_green_uri", request.URI,
168+
)
169+
return nil
170+
}
171+
}
172+
173+
// isNewerRequest reports whether candidate was ingested after current. An empty
174+
// current means the bookmark has never been set, so any candidate is newer.
175+
func isNewerRequest(queue, candidate, current string) (bool, error) {
176+
if current == "" {
177+
return true, nil
178+
}
179+
cmp, err := entity.CompareRequestID(queue, candidate, current)
180+
if err != nil {
181+
return false, fmt.Errorf("failed to compare request ids for queue %s: %w", queue, err)
182+
}
183+
return cmp > 0, nil
184+
}
185+
186+
// loadRequest loads the request by id.
187+
func (c *Controller) loadRequest(ctx context.Context, id string) (entity.Request, error) {
188+
return loader.ByID(ctx, id, c.store.GetRequestStore().Get, "request")
189+
}
190+
191+
// Name returns the controller name for logging and metrics.
192+
func (c *Controller) Name() string {
193+
return "record"
194+
}
195+
196+
// TopicKey returns the topic key this controller subscribes to.
197+
func (c *Controller) TopicKey() consumer.TopicKey {
198+
return c.topicKey
199+
}
200+
201+
// ConsumerGroup returns the consumer group for offset tracking.
202+
func (c *Controller) ConsumerGroup() string {
203+
return c.consumerGroup
204+
}

0 commit comments

Comments
 (0)