Skip to content

Commit ca245f5

Browse files
committed
feat(stovepipe): Promote green commits to a ref
1 parent 00882d5 commit ca245f5

10 files changed

Lines changed: 260 additions & 41 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: 4 additions & 1 deletion
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) {

stovepipe/controller/record/record.go

Lines changed: 86 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 (
@@ -39,8 +41,8 @@ import (
3941
)
4042

4143
// Controller consumes Record messages, records the build's validation fact, and
42-
// advances the queue's last-green bookmark when that fact is green. Implements
43-
// consumer.Controller.
44+
// when that fact is green advances the queue's last-green bookmark and promotes
45+
// the commit. Implements consumer.Controller.
4446
type Controller struct {
4547
logger *zap.SugaredLogger
4648
metricsScope tally.Scope
@@ -80,9 +82,10 @@ func NewController(
8082
}
8183
}
8284

83-
// Process loads the request referenced by the delivery and, when its build
84-
// succeeded, advances the queue's last-green bookmark. Returns nil to ack
85-
// (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).
8689
//
8790
// buildsignal stamps the outcome on the request before publishing here, so a
8891
// request without a build outcome is a producer invariant violation rather
@@ -133,11 +136,18 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
133136
}
134137
return nil
135138
}
136-
if err := c.advanceLastGreen(ctx, store, request); err != nil {
139+
holdsBookmark, err := c.advanceLastGreen(ctx, store, request)
140+
if err != nil {
137141
metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1)
138142
return err
139143
}
140-
return nil
144+
if !holdsBookmark {
145+
// A later green commit already holds the bookmark, so it also owns
146+
// the promotion ref: promoting this older commit would move the ref
147+
// backwards.
148+
return nil
149+
}
150+
return c.promote(ctx, request)
141151

142152
case entity.RequestStateCancelled:
143153
// A cancelled build decided nothing about the commit, so it establishes
@@ -296,30 +306,34 @@ func degreeFor(state entity.RequestState) float64 {
296306
}
297307

298308
// advanceLastGreen points the queue's bookmark at request, retrying on version
299-
// conflicts. The bookmark only moves forward: a candidate whose id is not newer
300-
// than the stored one is skipped without a write, which also makes a redelivery
301-
// of the same request a no-op.
309+
// conflicts, and reports whether request holds the bookmark afterwards. The
310+
// bookmark only moves forward: an older candidate is skipped without a write and
311+
// does not hold it, while a redelivery of the request that already set it holds it
312+
// without a write, so the promotion that follows is retried.
302313
//
303314
// The bookmark is a cache of "newest green URI" derived from the facts, so it is
304315
// advanced only after the green fact is durable. Losing the advance to a crash is
305316
// recoverable — the redelivery reloads the same fact and retries — whereas a
306317
// bookmark with no fact behind it would point at greenness nothing recorded.
307-
func (c *Controller) advanceLastGreen(ctx context.Context, store storage.Storage, request entity.Request) error {
318+
func (c *Controller) advanceLastGreen(ctx context.Context, store storage.Storage, request entity.Request) (bool, error) {
308319
queueStore := store.GetQueueStore()
309320

310321
for {
311322
queueRow, err := queueStore.Get(ctx, request.Queue)
312323
if err != nil {
313-
return fmt.Errorf("failed to load queue %s to advance last green: %w", request.Queue, err)
324+
return false, fmt.Errorf("failed to load queue %s to advance last green: %w", request.Queue, err)
314325
}
315326

316-
newer, err := isNewerRequest(request.Queue, request.ID, queueRow.LastGreenRequestID)
327+
cmp, err := compareToBookmark(request.Queue, request.ID, queueRow.LastGreenRequestID)
317328
if err != nil {
318329
// Non-retryable: re-parsing the same ids cannot start succeeding.
319-
return err
330+
return false, err
320331
}
321-
if !newer {
322-
return nil
332+
if cmp < 0 {
333+
return false, nil
334+
}
335+
if cmp == 0 {
336+
return true, nil
323337
}
324338

325339
updated := queueRow
@@ -330,7 +344,7 @@ func (c *Controller) advanceLastGreen(ctx context.Context, store storage.Storage
330344
if errors.Is(err, storage.ErrVersionMismatch) {
331345
continue
332346
}
333-
return fmt.Errorf("failed to advance last green for queue %s: %w", request.Queue, err)
347+
return false, fmt.Errorf("failed to advance last green for queue %s: %w", request.Queue, err)
334348
}
335349

336350
metrics.NamedCounter(c.metricsScope, _opName, "last_green_advanced", 1)
@@ -340,7 +354,7 @@ func (c *Controller) advanceLastGreen(ctx context.Context, store storage.Storage
340354
"last_green_uri", request.URI,
341355
)
342356
c.emitLastGreenTimestamp(ctx, request)
343-
return nil
357+
return true, nil
344358
}
345359
}
346360

@@ -396,17 +410,64 @@ func (c *Controller) emitLastGreenTimestamp(ctx context.Context, request entity.
396410
)
397411
}
398412

399-
// isNewerRequest reports whether candidate was ingested after current. An empty
400-
// current means the bookmark has never been set, so any candidate is newer.
401-
func isNewerRequest(queue, candidate, current string) (bool, error) {
413+
// promote points the queue's promotion ref at the request's commit so downstream
414+
// systems can pull the latest green commit by name. Which ref that is — and whether
415+
// the queue has one at all — is source-control configuration, so this stage names
416+
// only the commit.
417+
//
418+
// Like the bookmark, the ref is a cache of the facts, so it moves only after the
419+
// green fact is durable. Promotion is idempotent, so a redelivery repeats it
420+
// harmlessly. A commit that a rewritten history dropped from the ref cannot be
421+
// promoted by any retry, so that case is counted and skipped rather than failed.
422+
func (c *Controller) promote(ctx context.Context, request entity.Request) error {
423+
sc, err := c.sourceControls.For(sourcecontrol.Config{QueueName: request.Queue})
424+
if err != nil {
425+
metrics.NamedCounter(c.metricsScope, _opName, "source_control_errors", 1,
426+
metrics.NewTag("stage", "resolve"),
427+
)
428+
return fmt.Errorf("failed to resolve source control for queue %s: %w", request.Queue, err)
429+
}
430+
431+
if err := sc.Promote(ctx, request.URI); err != nil {
432+
if sourcecontrol.IsNotFound(err) {
433+
metrics.NamedCounter(c.metricsScope, _opName, "promotions_skipped", 1,
434+
metrics.NewTag("reason", "unknown_uri"),
435+
)
436+
c.logger.Warnw("green commit is no longer on the queue's ref; skipping promotion",
437+
"queue", request.Queue,
438+
"request_id", request.ID,
439+
"uri", request.URI,
440+
)
441+
return nil
442+
}
443+
444+
metrics.NamedCounter(c.metricsScope, _opName, "source_control_errors", 1,
445+
metrics.NewTag("stage", "promote"),
446+
)
447+
return fmt.Errorf("failed to promote uri %s of queue %s: %w", request.URI, request.Queue, err)
448+
}
449+
450+
metrics.NamedCounter(c.metricsScope, _opName, "promotions", 1)
451+
c.logger.Infow("promoted green commit",
452+
"queue", request.Queue,
453+
"request_id", request.ID,
454+
"uri", request.URI,
455+
)
456+
return nil
457+
}
458+
459+
// compareToBookmark orders candidate against the request id currently holding the
460+
// bookmark, by ingest order, using the sign convention of entity.CompareRequestID.
461+
// An empty current means the bookmark has never been set, so any candidate is newer.
462+
func compareToBookmark(queue, candidate, current string) (int, error) {
402463
if current == "" {
403-
return true, nil
464+
return 1, nil
404465
}
405466
cmp, err := entity.CompareRequestID(queue, candidate, current)
406467
if err != nil {
407-
return false, fmt.Errorf("failed to compare request ids for queue %s: %w", queue, err)
468+
return 0, fmt.Errorf("failed to compare request ids for queue %s: %w", queue, err)
408469
}
409-
return cmp > 0, nil
470+
return cmp, nil
410471
}
411472

412473
// loadRequest loads the request by id.

0 commit comments

Comments
 (0)