Skip to content

Commit 01d465e

Browse files
committed
feat(stovepipe): Promote green commits to a ref
1 parent a514a8c commit 01d465e

11 files changed

Lines changed: 289 additions & 46 deletions

File tree

doc/rfc/stovepipe/workflow.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,17 @@ Greenness is recorded as a **health degree** where **`0` means green** and **hig
4343

4444
A **project** is a caller-defined slice of the repository. Whole-repo greenness answers "is the branch green at this URI"; project greenness answers the question deployments actually need — **"is *this project* green at this URI"**, and its dual, "what is the latest URI at which this project is green". Projects are derived from the build's **target graph**: analysis sees which targets broke and maps them to projects. How targets map to projects is implementer-specific (directory ownership, build metadata, an external service) and lives behind the project-analysis stage, not in the core pipeline.
4545

46+
### Promotion ref — the last green commit, by name
47+
48+
A Queue may have a **promotion ref**: a stable branch name (say `verified-main` for `monorepo/main`) that Stovepipe advances to each commit it establishes green. A deploy gate or cache warmer then fetches that name and needs to know nothing about Stovepipe, URIs, or greenness degrees. It is the pull-shaped counterpart to Hooks' push: the same fact, available to consumers that would rather resolve a ref than subscribe to an event.
49+
50+
The ref is a *cache* of the last-green URI, not a second record of greenness. It only ever moves where the bookmark already points, so it inherits the same forward-only rule, and a commit that a history rewrite has dropped from the branch is skipped rather than retried — the next green commit corrects the ref. Which ref a Queue promotes to, and whether it has one at all, is `SourceControl` configuration resolved from the Queue name alongside the repo and credentials; the pipeline names only the commit, never a branch.
51+
4652
## Extensions
4753

4854
| Extension | Responsibility |
4955
|---|---|
50-
| **SourceControl** | Resolve a Queue name to its current head URI; answer ancestry/comparison questions between two URIs (is the new head a fast-forward descendant of the last green, or was history rewritten?); enumerate commits in a range. The sole owner of URI semantics. |
56+
| **SourceControl** | Resolve a Queue name to its current head URI; answer ancestry/comparison questions between two URIs (is the new head a fast-forward descendant of the last green, or was history rewritten?); enumerate commits in a range; advance the Queue's **promotion ref** to a commit. The sole owner of URI semantics, including which refs a Queue name resolves to. |
5157
| **build-runner** | Build a scope at a URI (optionally relative to a baseline URI), returning pass/fail and the target graph. See [build-runner.md](../submitqueue/build-runner.md). |
5258
| **Hooks** | Publish Stovepipe's greenness events to downstream systems — "this URI / this project is now green (or not green)". Fire-and-forget notification, decoupled so Stovepipe does not know or care who consumes the event. |
5359
| **Storage** | Persist Queues (incl. last-green URI), Requests, build records, and per-URI / per-project greenness. Key/value-shaped per the extension-design rules in [CLAUDE.md](../../../CLAUDE.md). |
@@ -129,7 +135,7 @@ The pipeline runs in two phases against the same Request. **Phase 1** establishe
129135
2. **process** — decides build strategy (incremental since last-green vs full monorepo), gates concurrent work per Queue, coalesces backlog to the latest head, and publishes to `build`. See [process.md](steps/process.md).
130136
3. **build** — runs the build-runner for the chosen scope. A flag derived from `process` decides whether to build relative to the last-green **baseline URI** (incremental) or from scratch (full). It records a build and publishes the BuildID.
131137
4. **buildsignal** — records the build's status and target graph when the build completes, then releases the Queue's `in_flight_count` slot, projects the terminal status onto the Request (`succeeded` / `failed` / `cancelled`), and publishes the RequestID to `record`.
132-
5. **record** — writes the whole-repo greenness for the head URI (`0` green / `1` broken to start), derived from the Request's build outcome. On green it advances the Queue's **last-green URI** so the next `process` can build incrementally from here. It fires the **Hooks** extension with the green/not-green event, then fans out into Phase 2. The Queue's `in_flight_count` was already released by `buildsignal` when the build went terminal.
138+
5. **record** — writes the whole-repo greenness for the head URI (`0` green / `1` broken to start), derived from the Request's build outcome. On green it advances the Queue's **last-green URI** so the next `process` can build incrementally from here, and asks `SourceControl` to advance the Queue's **promotion ref** to the same commit (see [Promotion ref](#promotion-ref)). It fires the **Hooks** extension with the green/not-green event, then fans out into Phase 2. The Queue's `in_flight_count` was already released by `buildsignal` when the build went terminal.
133139

134140
### Phase 2 — project greenness
135141

@@ -147,7 +153,7 @@ The pipeline runs in two phases against the same Request. **Phase 1** establishe
147153
| **process** | RequestID | build | Build strategy, concurrency gate, backlog coalescing → [process.md](steps/process.md) |
148154
| **build** | RequestID | buildsignal | Run the build-runner for the chosen scope; baseline = last-green URI iff incremental |
149155
| **buildsignal** | BuildID | record (P1), record (P2) | Record build status + target graph; release `in_flight_count`; project the outcome onto the Request; signal completion |
150-
| **record** | RequestID | analyze (P1→P2), Hooks | Write greenness; advance last-green URI on whole-repo green; fire Hooks |
156+
| **record** | RequestID | analyze (P1→P2), Hooks | Write greenness; on whole-repo green advance last-green URI and the promotion ref; fire Hooks |
151157
| **analyze** | RequestID | build | Map broken/at-risk targets → projects; decide project-scoped builds |
152158

153159
## Step RFCs

service/stovepipe/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Stovepipe therefore needs two MySQL databases: a **storage** database (the `requ
1515
`server/main.go` is the composition root and supplies the concrete extension implementations. Two are deliberately demo-only and must be replaced for any real deployment:
1616

1717
- **`inMemoryCounter`** — a process-local `counter.Counter` for sequence numbers; not durable. A real deployment uses a persistent implementation (e.g. `platform/extension/counter/mysql`).
18-
- **`fakeSourceControlFactory`** — seeds each queue with a deterministic single-commit history so ingest resolves a stable head URI (and re-ingesting the same queue exercises the dedup path). A real deployment supplies a VCS-backed `sourcecontrol.Factory`.
18+
- **`fakeSourceControlFactory`** — seeds each queue with a deterministic single-commit history so ingest resolves a stable head URI (and re-ingesting the same queue exercises the dedup path). A real deployment supplies a VCS-backed `sourcecontrol.Factory`, which is also where a queue's promotion ref is resolved. The fake has no ref to move, so a promotion locally shows up only in the record consumer's logs.
1919

2020
## Layout
2121

service/stovepipe/server/main.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,10 @@ func (f *inMemoryCounterFactory) For(config counter.Config) (counter.Counter, er
130130

131131
// fakeSourceControlFactory is the example SourceControl factory. It seeds each queue with a
132132
// deterministic single-commit history so ingest resolves a stable head URI (and re-ingesting
133-
// the same queue exercises the dedup path). A real deployment supplies a VCS-backed factory.
133+
// the same queue exercises the dedup path). It has no ref to promote onto, so a promotion
134+
// only succeeds or reports the commit as gone, and the local stack shows it in the record
135+
// consumer's log. A real deployment supplies a VCS-backed factory, which is also where the
136+
// promotion ref is resolved from the queue name, alongside the repo and credentials.
134137
type fakeSourceControlFactory struct{}
135138

136139
func (fakeSourceControlFactory) For(cfg sourcecontrol.Config) (sourcecontrol.SourceControl, error) {
@@ -426,7 +429,7 @@ func registerPrimaryControllers(
426429
}
427430
count++
428431

429-
recordController := record.NewController(logger, scope, store, stovepipemq.TopicKeyRecord, "stovepipe-record")
432+
recordController := record.NewController(logger, scope, store, scf, stovepipemq.TopicKeyRecord, "stovepipe-record")
430433
if err := c.Register(recordController); err != nil {
431434
return count, fmt.Errorf("failed to register record controller: %w", err)
432435
}

stovepipe/controller/record/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ go_library(
1111
"//stovepipe/core/loader:go_default_library",
1212
"//stovepipe/core/messagequeue:go_default_library",
1313
"//stovepipe/entity:go_default_library",
14+
"//stovepipe/extension/sourcecontrol:go_default_library",
1415
"//stovepipe/extension/storage:go_default_library",
1516
"@com_github_uber_go_tally//:go_default_library",
1617
"@org_uber_go_zap//:go_default_library",
@@ -26,6 +27,8 @@ go_test(
2627
"//platform/consumer/mock:go_default_library",
2728
"//stovepipe/core/messagequeue:go_default_library",
2829
"//stovepipe/entity:go_default_library",
30+
"//stovepipe/extension/sourcecontrol:go_default_library",
31+
"//stovepipe/extension/sourcecontrol/mock:go_default_library",
2932
"//stovepipe/extension/storage:go_default_library",
3033
"//stovepipe/extension/storage/mock:go_default_library",
3134
"@com_github_stretchr_testify//assert:go_default_library",

stovepipe/controller/record/record.go

Lines changed: 90 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
//
1919
// The durable state is a ValidationFact per validated commit, plus the queue's
2020
// last-green bookmark, which process reads to choose an incremental build
21-
// baseline. Downstream hooks are not implemented yet.
21+
// baseline. A green commit is also promoted, moving the queue's promotion ref so
22+
// downstream systems can pull the latest green commit by name. Downstream hooks
23+
// are not implemented yet.
2224
package record
2325

2426
import (
@@ -33,17 +35,19 @@ import (
3335
"github.com/uber/submitqueue/stovepipe/core/loader"
3436
stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue"
3537
"github.com/uber/submitqueue/stovepipe/entity"
38+
"github.com/uber/submitqueue/stovepipe/extension/sourcecontrol"
3639
"github.com/uber/submitqueue/stovepipe/extension/storage"
3740
"go.uber.org/zap"
3841
)
3942

4043
// Controller consumes Record messages, records the build's validation fact, and
41-
// advances the queue's last-green bookmark when that fact is green. Implements
42-
// consumer.Controller.
44+
// when that fact is green advances the queue's last-green bookmark and promotes
45+
// the commit. Implements consumer.Controller.
4346
type Controller struct {
4447
logger *zap.SugaredLogger
4548
metricsScope tally.Scope
4649
stores storage.Factory
50+
sourceControl sourcecontrol.Factory
4751
topicKey consumer.TopicKey
4852
consumerGroup string
4953
}
@@ -64,21 +68,24 @@ func NewController(
6468
logger *zap.SugaredLogger,
6569
scope tally.Scope,
6670
stores storage.Factory,
71+
sourceControl sourcecontrol.Factory,
6772
topicKey consumer.TopicKey,
6873
consumerGroup string,
6974
) *Controller {
7075
return &Controller{
7176
logger: logger.Named("record_controller"),
7277
metricsScope: scope.SubScope("record_controller"),
7378
stores: stores,
79+
sourceControl: sourceControl,
7480
topicKey: topicKey,
7581
consumerGroup: consumerGroup,
7682
}
7783
}
7884

79-
// Process loads the request referenced by the delivery and, when its build
80-
// succeeded, advances the queue's last-green bookmark. Returns nil to ack
81-
// (success) or an error to nack (retry) / reject (DLQ).
85+
// Process loads the request referenced by the delivery, records its validation
86+
// fact and, when that fact is green, advances the queue's last-green bookmark and
87+
// promotes the commit. Returns nil to ack (success) or an error to nack (retry) /
88+
// reject (DLQ).
8289
//
8390
// buildsignal stamps the outcome on the request before publishing here, so a
8491
// request without a build outcome is a producer invariant violation rather
@@ -123,11 +130,18 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
123130
metrics.NamedCounter(c.metricsScope, _opName, "not_green", 1)
124131
return nil
125132
}
126-
if err := c.advanceLastGreen(ctx, store, request); err != nil {
133+
holdsBookmark, err := c.advanceLastGreen(ctx, store, request)
134+
if err != nil {
127135
metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1)
128136
return err
129137
}
130-
return nil
138+
if !holdsBookmark {
139+
// A later green commit already holds the bookmark, so it also owns
140+
// the promotion ref: promoting this older commit would move the ref
141+
// backwards.
142+
return nil
143+
}
144+
return c.promote(ctx, request)
131145

132146
case entity.RequestStateCancelled:
133147
// A cancelled build decided nothing about the commit, so it establishes
@@ -213,30 +227,34 @@ func degreeFor(state entity.RequestState) float64 {
213227
}
214228

215229
// advanceLastGreen points the queue's bookmark at request, retrying on version
216-
// conflicts. The bookmark only moves forward: a candidate whose id is not newer
217-
// than the stored one is skipped without a write, which also makes a redelivery
218-
// of the same request a no-op.
230+
// conflicts, and reports whether request holds the bookmark afterwards. The
231+
// bookmark only moves forward: an older candidate is skipped without a write and
232+
// does not hold it, while a redelivery of the request that already set it holds it
233+
// without a write, so the promotion that follows is retried.
219234
//
220235
// The bookmark is a cache of "newest green URI" derived from the facts, so it is
221236
// advanced only after the green fact is durable. Losing the advance to a crash is
222237
// recoverable — the redelivery reloads the same fact and retries — whereas a
223238
// bookmark with no fact behind it would point at greenness nothing recorded.
224-
func (c *Controller) advanceLastGreen(ctx context.Context, store storage.Storage, request entity.Request) error {
239+
func (c *Controller) advanceLastGreen(ctx context.Context, store storage.Storage, request entity.Request) (bool, error) {
225240
queueStore := store.GetQueueStore()
226241

227242
for {
228243
queueRow, err := queueStore.Get(ctx, request.Queue)
229244
if err != nil {
230-
return fmt.Errorf("failed to load queue %s to advance last green: %w", request.Queue, err)
245+
return false, fmt.Errorf("failed to load queue %s to advance last green: %w", request.Queue, err)
231246
}
232247

233-
newer, err := isNewerRequest(request.Queue, request.ID, queueRow.LastGreenRequestID)
248+
cmp, err := compareToBookmark(request.Queue, request.ID, queueRow.LastGreenRequestID)
234249
if err != nil {
235250
// Non-retryable: re-parsing the same ids cannot start succeeding.
236-
return err
251+
return false, err
237252
}
238-
if !newer {
239-
return nil
253+
if cmp < 0 {
254+
return false, nil
255+
}
256+
if cmp == 0 {
257+
return true, nil
240258
}
241259

242260
updated := queueRow
@@ -247,7 +265,7 @@ func (c *Controller) advanceLastGreen(ctx context.Context, store storage.Storage
247265
if errors.Is(err, storage.ErrVersionMismatch) {
248266
continue
249267
}
250-
return fmt.Errorf("failed to advance last green for queue %s: %w", request.Queue, err)
268+
return false, fmt.Errorf("failed to advance last green for queue %s: %w", request.Queue, err)
251269
}
252270

253271
metrics.NamedCounter(c.metricsScope, _opName, "last_green_advanced", 1)
@@ -256,21 +274,68 @@ func (c *Controller) advanceLastGreen(ctx context.Context, store storage.Storage
256274
"request_id", request.ID,
257275
"last_green_uri", request.URI,
258276
)
259-
return nil
277+
return true, nil
260278
}
261279
}
262280

263-
// isNewerRequest reports whether candidate was ingested after current. An empty
264-
// current means the bookmark has never been set, so any candidate is newer.
265-
func isNewerRequest(queue, candidate, current string) (bool, error) {
281+
// promote points the queue's promotion ref at the request's commit so downstream
282+
// systems can pull the latest green commit by name. Which ref that is — and whether
283+
// the queue has one at all — is source-control configuration, so this stage names
284+
// only the commit.
285+
//
286+
// Like the bookmark, the ref is a cache of the facts, so it moves only after the
287+
// green fact is durable. Promotion is idempotent, so a redelivery repeats it
288+
// harmlessly. A commit that a rewritten history dropped from the ref cannot be
289+
// promoted by any retry, so that case is counted and skipped rather than failed.
290+
func (c *Controller) promote(ctx context.Context, request entity.Request) error {
291+
sc, err := c.sourceControl.For(sourcecontrol.Config{QueueName: request.Queue})
292+
if err != nil {
293+
metrics.NamedCounter(c.metricsScope, _opName, "source_control_errors", 1,
294+
metrics.NewTag("stage", "resolve"),
295+
)
296+
return fmt.Errorf("failed to resolve source control for queue %s: %w", request.Queue, err)
297+
}
298+
299+
if err := sc.Promote(ctx, request.URI); err != nil {
300+
if sourcecontrol.IsNotFound(err) {
301+
metrics.NamedCounter(c.metricsScope, _opName, "promotions_skipped", 1,
302+
metrics.NewTag("reason", "unknown_uri"),
303+
)
304+
c.logger.Warnw("green commit is no longer on the queue's ref; skipping promotion",
305+
"queue", request.Queue,
306+
"request_id", request.ID,
307+
"uri", request.URI,
308+
)
309+
return nil
310+
}
311+
312+
metrics.NamedCounter(c.metricsScope, _opName, "source_control_errors", 1,
313+
metrics.NewTag("stage", "promote"),
314+
)
315+
return fmt.Errorf("failed to promote uri %s of queue %s: %w", request.URI, request.Queue, err)
316+
}
317+
318+
metrics.NamedCounter(c.metricsScope, _opName, "promotions", 1)
319+
c.logger.Infow("promoted green commit",
320+
"queue", request.Queue,
321+
"request_id", request.ID,
322+
"uri", request.URI,
323+
)
324+
return nil
325+
}
326+
327+
// compareToBookmark orders candidate against the request id currently holding the
328+
// bookmark, by ingest order, using the sign convention of entity.CompareRequestID.
329+
// An empty current means the bookmark has never been set, so any candidate is newer.
330+
func compareToBookmark(queue, candidate, current string) (int, error) {
266331
if current == "" {
267-
return true, nil
332+
return 1, nil
268333
}
269334
cmp, err := entity.CompareRequestID(queue, candidate, current)
270335
if err != nil {
271-
return false, fmt.Errorf("failed to compare request ids for queue %s: %w", queue, err)
336+
return 0, fmt.Errorf("failed to compare request ids for queue %s: %w", queue, err)
272337
}
273-
return cmp > 0, nil
338+
return cmp, nil
274339
}
275340

276341
// loadRequest loads the request by id.

0 commit comments

Comments
 (0)