Skip to content

Commit 56f126f

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

5 files changed

Lines changed: 595 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"
@@ -394,6 +395,12 @@ func registerPrimaryControllers(
394395
}
395396
count++
396397

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

@@ -421,8 +428,8 @@ func registerDLQControllers(
421428
// publishes to the process topic and the process consumer subscribes to it; process publishes
422429
// to the build topic and the build consumer subscribes to it; build publishes to the buildsignal
423430
// topic and the buildsignal consumer subscribes to it, and also republishes to itself while
424-
// polling. buildsignal publishes to the record topic once a build reaches a terminal status; it
425-
// has no Subscription yet since no consumer for it exists until the record stage lands.
431+
// polling. buildsignal publishes to the record topic once a build reaches a terminal status,
432+
// and the record consumer subscribes to it.
426433
func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRegistry, error) {
427434
return consumer.NewTopicRegistry([]consumer.TopicConfig{
428435
{
@@ -453,6 +460,9 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe
453460
Key: stovepipemq.TopicKeyRecord,
454461
Name: "record",
455462
Queue: q,
463+
Subscription: extqueue.DefaultSubscriptionConfig(
464+
subscriberName, "stovepipe-record",
465+
),
456466
},
457467
{
458468
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: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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+
stores storage.Factory
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+
stores storage.Factory,
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+
stores: stores,
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+
store, err := c.stores.For(storage.Config{QueueName: rec.GetQueueName()})
91+
if err != nil {
92+
metrics.NamedCounter(c.metricsScope, _opName, "storage_resolve_errors", 1)
93+
// Non-retryable: a missing or unresolvable queue is a malformed message.
94+
return fmt.Errorf("failed to resolve storage for queue %q: %w", rec.GetQueueName(), err)
95+
}
96+
97+
request, err := c.loadRequest(ctx, store, rec.Id)
98+
if err != nil {
99+
metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1)
100+
return err
101+
}
102+
103+
// The payload's queue must match the request's authoritative queue; a
104+
// mismatch is a malformed message. Non-retryable — reject to the DLQ.
105+
if rec.GetQueueName() != "" && rec.GetQueueName() != request.Queue {
106+
metrics.NamedCounter(c.metricsScope, _opName, "queue_mismatch", 1)
107+
return fmt.Errorf("payload queue %q does not match queue %q of request %s", rec.GetQueueName(), request.Queue, request.ID)
108+
}
109+
110+
switch request.State {
111+
case entity.RequestStateSucceeded:
112+
if err := c.advanceLastGreen(ctx, store, request); err != nil {
113+
metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1)
114+
return err
115+
}
116+
return nil
117+
118+
case entity.RequestStateFailed, entity.RequestStateCancelled:
119+
// A verdict, but not a green one: nothing to record in phase 1. A
120+
// cancelled build decided nothing about the commit at all.
121+
metrics.NamedCounter(c.metricsScope, _opName, "not_green", 1)
122+
return nil
123+
124+
case entity.RequestStateSuperseded:
125+
// Terminal without a build outcome. buildsignal never publishes for a
126+
// superseded request, so this is unreachable in practice.
127+
metrics.NamedCounter(c.metricsScope, _opName, "superseded", 1)
128+
return nil
129+
130+
default:
131+
// Non-retryable: buildsignal publishes only after committing the
132+
// outcome, so a non-terminal request here is a broken invariant that
133+
// retrying cannot fix.
134+
metrics.NamedCounter(c.metricsScope, _opName, "invariant_errors", 1)
135+
return fmt.Errorf("request %s reached record in non-terminal state %q", request.ID, request.State)
136+
}
137+
}
138+
139+
// advanceLastGreen points the queue's bookmark at request, retrying on version
140+
// conflicts. The bookmark only moves forward: a candidate whose id is not newer
141+
// than the stored one is skipped without a write, which also makes a redelivery
142+
// of the same request a no-op.
143+
//
144+
// Greenness comes from request.State rather than a persisted validation fact,
145+
// which is what record.md specifies. The two agree — a fact's degree is derived
146+
// from the same immutable state — and this reads the fact once the fact store
147+
// lands.
148+
func (c *Controller) advanceLastGreen(ctx context.Context, store storage.Storage, request entity.Request) error {
149+
queueStore := store.GetQueueStore()
150+
151+
for {
152+
queueRow, err := queueStore.Get(ctx, request.Queue)
153+
if err != nil {
154+
return fmt.Errorf("failed to load queue %s to advance last green: %w", request.Queue, err)
155+
}
156+
157+
newer, err := isNewerRequest(request.Queue, request.ID, queueRow.LastGreenRequestID)
158+
if err != nil {
159+
// Non-retryable: re-parsing the same ids cannot start succeeding.
160+
return err
161+
}
162+
if !newer {
163+
return nil
164+
}
165+
166+
updated := queueRow
167+
updated.LastGreenURI = request.URI
168+
updated.LastGreenRequestID = request.ID
169+
newVersion := queueRow.Version + 1
170+
if err := queueStore.Update(ctx, updated, queueRow.Version, newVersion); err != nil {
171+
if errors.Is(err, storage.ErrVersionMismatch) {
172+
continue
173+
}
174+
return fmt.Errorf("failed to advance last green for queue %s: %w", request.Queue, err)
175+
}
176+
177+
metrics.NamedCounter(c.metricsScope, _opName, "last_green_advanced", 1)
178+
c.logger.Infow("advanced last green bookmark",
179+
"queue", request.Queue,
180+
"request_id", request.ID,
181+
"last_green_uri", request.URI,
182+
)
183+
return nil
184+
}
185+
}
186+
187+
// isNewerRequest reports whether candidate was ingested after current. An empty
188+
// current means the bookmark has never been set, so any candidate is newer.
189+
func isNewerRequest(queue, candidate, current string) (bool, error) {
190+
if current == "" {
191+
return true, nil
192+
}
193+
cmp, err := entity.CompareRequestID(queue, candidate, current)
194+
if err != nil {
195+
return false, fmt.Errorf("failed to compare request ids for queue %s: %w", queue, err)
196+
}
197+
return cmp > 0, nil
198+
}
199+
200+
// loadRequest loads the request by id.
201+
func (c *Controller) loadRequest(ctx context.Context, store storage.Storage, id string) (entity.Request, error) {
202+
return loader.ByID(ctx, id, store.GetRequestStore().Get, "request")
203+
}
204+
205+
// Name returns the controller name for logging and metrics.
206+
func (c *Controller) Name() string {
207+
return "record"
208+
}
209+
210+
// TopicKey returns the topic key this controller subscribes to.
211+
func (c *Controller) TopicKey() consumer.TopicKey {
212+
return c.topicKey
213+
}
214+
215+
// ConsumerGroup returns the consumer group for offset tracking.
216+
func (c *Controller) ConsumerGroup() string {
217+
return c.consumerGroup
218+
}

0 commit comments

Comments
 (0)