Skip to content

Commit e37133d

Browse files
authored
feat(stovepipe): Announce validation start events (#652)
## Summary **What**: - Announce that validation of a commit has begun as soon as the pipeline admits it, alongside the existing announcements for validation outcomes. - Repeat that announcement when an already-admitted request is redelivered, reusing the original event identity so consumers can discard the duplicate. **Why**: - Let downstream systems track a validation from start to finish, instead of only learning it happened once a verdict exists. - Keep the announcement from being lost when the pipeline fails after durably admitting the request. ## Test Plan - [x] Add unit tests. ## Revert Plan - Revert this PR. Nothing consumes the start event yet, so publishing just stops with no downstream impact. ## Issues - [CODEM-466](https://linear.app/uber/issue/CODEM-466/emit-events-when-we-start-a-new-validation-range)
1 parent 8dc4fbb commit e37133d

7 files changed

Lines changed: 155 additions & 16 deletions

File tree

doc/rfc/stovepipe/steps/process.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ For a delivery carrying request id `R`:
2121
2. If R.State is terminal (superseded / succeeded / failed / cancelled):
2222
- ack and return (idempotent no-op).
2323
3. If R.State is processing (strategy already recorded):
24-
- re-publish R to build (the prior publish may have failed), ack, return.
24+
- re-announce validation start and re-publish R to build (either prior publish may have failed), ack, return.
2525
4. R.State is accepted. Load the Queue row Q.
2626
5. Coalesce: if CompareRequestID(R.Queue, R.ID, Q.latest_request_id) < 0:
2727
- a newer head exists -> mark R superseded, ack, return. (No slot consumed.)
@@ -31,8 +31,9 @@ For a delivery carrying request id `R`:
3131
a. Derive build strategy + baseline (see "Build-strategy decision").
3232
b. CAS the Queue row: in_flight_count += 1.
3333
c. CAS the Request: accepted -> processing, persist build_strategy + base_uri.
34-
d. Publish R to build.
35-
e. ack.
34+
d. Announce validation start on the hook topic (see "Hooks").
35+
e. Publish R to build.
36+
f. ack.
3637
```
3738

3839
Step 5 runs regardless of the gate: an intermediate head is superseded on sight (even mid-validation), because superseding consumes no slot.
@@ -138,12 +139,28 @@ A, D, F each get a full cycle; B, C, E end `superseded`. No intermediate is vali
138139
- A newer head does **not** preempt an in-flight validation.
139140
- Deferred messages are **not** failed or dead-lettered — they wait for the gate (see [Waiting for a slot](#waiting-for-a-slot)).
140141

142+
## Hooks
143+
144+
Admitting a request is when the rest of the company can learn "validation of this commit has begun". `process` publishes that as a `HookEvent` on Stovepipe's durable `hook` topic — the same seam `record` uses to announce the outcome. The mechanics (envelope, delivery promise, per-domain dispatcher stage, `hook_dlq`) are settled in [hook-framework.md](../../hook-framework.md); this section covers only what admitting has to decide.
145+
146+
The event type is `validation.repository.started`. Its payload names the Queue and the Request and nothing else, exactly as the terminal events in [record.md](record.md#hooks) do: a hook resolves the commit, the chosen strategy, and the baseline from the request store rather than reading a snapshot off the wire.
147+
148+
Published after the admit CAS and before the publish to `build`:
149+
150+
```
151+
CAS accepted -> processing → publish HookEvent → publish to build → ack
152+
```
153+
154+
After the CAS because the payload names the Request rather than snapshotting it, and the two facts a start event exists to carry — the scope it chose and the baseline it builds on — are written by that very CAS. A hook that reloads the Request must not find it still `accepted` with neither set. Before the build publish because the announce is the cheaper of the two to retry: a failed announce leaves nothing downstream to undo, whereas announcing after the build publish would make a failed announce force the redelivery to re-publish a build that was already accepted.
155+
156+
Only an admit announces. A Request that coalescing supersedes never reaches step 7, so it produces no start event — and a start event is not a promise that a verdict follows, since an admitted Request can still be cancelled or driven to a fail-closed outcome. Consumers pairing a start with an end must tolerate a start that never gets one.
157+
141158
## Idempotency and at-least-once delivery
142159

143160
Every branch is safe under redelivery:
144161

145162
- **accepted, no strategy** → full admit path. On a crash after incrementing `in_flight_count` but before persisting `processing`, redelivery re-reads `accepted` and re-runs; the increment re-applies only if the count CAS hasn't already moved (see integrity below).
146-
- **processing** → re-publish to `build` and ack. The `build` consumer is keyed on the request id and idempotent, so a duplicate publish is harmless.
163+
- **processing** → re-announce the start event, re-publish to `build`, ack. The `build` consumer is keyed on the request id and idempotent, so a duplicate publish is harmless, and the start event's id is derived from the transition rather than the clock, so a re-announce carries the id the first attempt would have and consumers dedupe on it. Re-announcing here is what makes the event at-least-once rather than at-most-once: this is the only branch a redelivery takes once `processing` is durable, so an admit that failed after the state write would otherwise lose the event for good.
147164
- **terminal** (superseded / recorded) → ack, no-op.
148165
- **deferred (waiting for slot)** → no state or count change; pure deferral (re-enters when the held delivery comes due).
149166

doc/rfc/stovepipe/workflow.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@ The ref is a *cache* of the last-green URI, not a second record of greenness. It
5555
|---|---|
5656
| **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. |
5757
| **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). |
58-
| **Hooks** | Deliver 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. The shared cross-domain hook seam rather than a Stovepipe-specific extension. See [hook-framework.md](../hook-framework.md). |
58+
| **Hooks** | Deliver Stovepipe's validation events to downstream systems — "validation of this URI has begun", "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. The shared cross-domain hook seam rather than a Stovepipe-specific extension. See [hook-framework.md](../hook-framework.md). |
5959
| **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 [AGENTS.md](../../../AGENTS.md). |
6060

61-
Hooks are the notification boundary. When a validation fact is recorded — whole-repo green/not-green, or later a project green/not-green — the event reaches deployment systems, dashboards, and developer tooling without any of them polling Stovepipe's store, and each environment can route it to its own downstream (a deploy gate, a Slack notifier, an event bus) without changing the pipeline. The mechanism is the cross-domain hook framework rather than a call out of the recording stage: `record` publishes a `HookEvent` to Stovepipe's `hook` topic, and a dispatcher stage consumes it and invokes the wired hooks, so a slow or failing downstream cannot add latency to the pipeline. Both halves exist; what a deployment supplies is the hooks themselves, since the example server resolves every event to `noop`. See [record.md](steps/record.md#hooks) for the fact-to-event mapping.
61+
Hooks are the notification boundary. When validation of a commit begins, and when a validation fact is recorded — whole-repo green/not-green, or later a project green/not-green — the event reaches deployment systems, dashboards, and developer tooling without any of them polling Stovepipe's store, and each environment can route it to its own downstream (a deploy gate, a Slack notifier, an event bus) without changing the pipeline. The mechanism is the cross-domain hook framework rather than a call out of the pipeline stages: `process` and `record` publish a `HookEvent` to Stovepipe's `hook` topic, and a dispatcher stage consumes it and invokes the wired hooks, so a slow or failing downstream cannot add latency to the pipeline. Both halves exist; what a deployment supplies is the hooks themselves, since the example server resolves every event to `noop`. See [process.md](steps/process.md#hooks) for the start event and [record.md](steps/record.md#hooks) for the fact-to-event mapping.
6262

6363
## Workflow
6464

@@ -74,9 +74,9 @@ The pipeline runs in two phases against the same Request. **Phase 1** establishe
7474
└───────────────┬──────────────┘
7575
│ RequestID
7676
77-
┌──────────────────────────────┐
78-
│ process │
79-
│ Ask SourceControl: is head a │
77+
┌──────────────────────────────┐ Hooks
78+
│ process │┄┄┄┄┄► "validation
79+
│ Ask SourceControl: is head a │ started"
8080
│ descendant of last-green? │
8181
│ → incremental since green │
8282
│ else (history rewrite) │
@@ -132,7 +132,7 @@ The pipeline runs in two phases against the same Request. **Phase 1** establishe
132132
### Phase 1 — whole-repo greenness
133133

134134
1. **ingest** — invoked by the external poller with a **Queue name**. It asks `SourceControl` for that Queue's current head URI, mints a Request namespaced by the Queue, persists it with no recorded greenness yet, and dedups on `(Queue, head URI)` so a re-reported head is processed once. It publishes the RequestID onward.
135-
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).
135+
2. **process** — decides build strategy (incremental since last-green vs full monorepo), gates concurrent work per Queue, coalesces backlog to the latest head, publishes a **hook event** announcing that validation of the commit has begun, and publishes to `build`. See [process.md](steps/process.md).
136136
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.
137137
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`.
138138
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--the-last-green-commit-by-name)). It publishes a **hook event** for the green/not-green transition, then fans out into Phase 2. The Queue's `in_flight_count` was already released by `buildsignal` when the build went terminal.
@@ -150,7 +150,7 @@ The pipeline runs in two phases against the same Request. **Phase 1** establishe
150150
| Controller | In | Out | One-line role |
151151
|---|---|---|---|
152152
| **ingest** | Queue name (from poller) | process | Resolve head URI via SourceControl, mint Request, persist (no greenness), dedup on `(Queue, head URI)` |
153-
| **process** | RequestID | build | Build strategy, concurrency gate, backlog coalescing → [process.md](steps/process.md) |
153+
| **process** | RequestID | build, hook topic | Build strategy, concurrency gate, backlog coalescing; announce validation start on admit[process.md](steps/process.md) |
154154
| **build** | RequestID | buildsignal | Run the build-runner for the chosen scope; baseline = last-green URI iff incremental |
155155
| **buildsignal** | BuildID | record (P1), record (P2) | Record build status + target graph; release `in_flight_count`; project the outcome onto the Request; signal completion |
156156
| **record** | RequestID | analyze (P1→P2), hook topic | Write greenness; on whole-repo green advance last-green URI and the promotion ref; publish the hook event |

stovepipe/controller/process/BUILD.bazel

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ go_library(
66
importpath = "github.com/uber/submitqueue/stovepipe/controller/process",
77
visibility = ["//visibility:public"],
88
deps = [
9+
"//api/base/hook:go_default_library",
910
"//platform/consumer:go_default_library",
1011
"//platform/errs:go_default_library",
12+
"//platform/hook:go_default_library",
1113
"//platform/metrics:go_default_library",
1214
"//platform/publish:go_default_library",
15+
"//stovepipe/core/hookevent:go_default_library",
1316
"//stovepipe/core/loader:go_default_library",
1417
"//stovepipe/core/messagequeue:go_default_library",
1518
"//stovepipe/entity:go_default_library",
@@ -26,12 +29,14 @@ go_test(
2629
srcs = ["process_test.go"],
2730
embed = [":go_default_library"],
2831
deps = [
32+
"//api/base/hook:go_default_library",
2933
"//platform/base/messagequeue:go_default_library",
3034
"//platform/consumer:go_default_library",
3135
"//platform/consumer/mock:go_default_library",
3236
"//platform/errs:go_default_library",
3337
"//platform/extension/messagequeue/mock:go_default_library",
3438
"//platform/metrics:go_default_library",
39+
"//stovepipe/core/hookevent:go_default_library",
3540
"//stovepipe/core/messagequeue:go_default_library",
3641
"//stovepipe/entity:go_default_library",
3742
"//stovepipe/extension/queueconfig/default:go_default_library",

stovepipe/controller/process/process.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,13 @@ import (
2424
"fmt"
2525

2626
"github.com/uber-go/tally"
27+
basehook "github.com/uber/submitqueue/api/base/hook"
2728
"github.com/uber/submitqueue/platform/consumer"
2829
"github.com/uber/submitqueue/platform/errs"
30+
platformhook "github.com/uber/submitqueue/platform/hook"
2931
"github.com/uber/submitqueue/platform/metrics"
3032
"github.com/uber/submitqueue/platform/publish"
33+
"github.com/uber/submitqueue/stovepipe/core/hookevent"
3134
"github.com/uber/submitqueue/stovepipe/core/loader"
3235
stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue"
3336
"github.com/uber/submitqueue/stovepipe/entity"
@@ -113,6 +116,14 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
113116

114117
switch request.State {
115118
case entity.RequestStateProcessing:
119+
// Announce here as well as at admit: this is the only path a redelivery
120+
// takes once the transition is durable, so an admit that failed after
121+
// persisting would otherwise lose the start event for good. The event id
122+
// is derived from the transition, so a repeat carries the id the first
123+
// attempt would have and a consumer deduplicates on it.
124+
if err := c.publishHookEvent(ctx, request, hookevent.NewValidationRepositoryStarted(request)); err != nil {
125+
return err
126+
}
116127
if err := c.publishBuild(ctx, request.ID, request.Queue); err != nil {
117128
metrics.NamedCounter(c.metricsScope, _opName, "publish_errors", 1, metrics.TagsFromContext(ctx)...)
118129
return fmt.Errorf("failed to publish request %s to build: %w", request.ID, err)
@@ -252,6 +263,10 @@ func (c *Controller) admitLatestHead(ctx context.Context, store storage.Storage,
252263
return nil
253264
}
254265

266+
if err := c.publishHookEvent(ctx, request, hookevent.NewValidationRepositoryStarted(request)); err != nil {
267+
return err
268+
}
269+
255270
if err := c.publishBuild(ctx, request.ID, request.Queue); err != nil {
256271
metrics.NamedCounter(c.metricsScope, _opName, "publish_errors", 1, metrics.TagsFromContext(ctx)...)
257272
return fmt.Errorf("failed to publish request %s to build: %w", request.ID, err)
@@ -474,6 +489,33 @@ func (c *Controller) publishBuild(ctx context.Context, id, queue string) error {
474489
return nil
475490
}
476491

492+
// publishHookEvent announces a lifecycle transition on the hook topic.
493+
//
494+
// Published only once the transition is durable: the payload names the request
495+
// rather than snapshotting it, so a hook that reloads it must not find a request
496+
// whose strategy and baseline are still unwritten.
497+
//
498+
// Partitioning by request id matches the process topic's own, carrying
499+
// per-request ordering across the seam.
500+
func (c *Controller) publishHookEvent(ctx context.Context, request entity.Request, event *basehook.HookEvent) error {
501+
if err := platformhook.Publish(ctx, c.registry, event, request.ID); err != nil {
502+
metrics.NamedCounter(c.metricsScope, _opName, "hook_errors", 1, metrics.TagsFromContext(ctx)...)
503+
return fmt.Errorf("failed to announce %s for request %s: %w", event.GetType(), request.ID, err)
504+
}
505+
506+
metrics.NamedCounter(c.metricsScope, _opName, "hook_events_published", 1,
507+
metrics.TagsFromContext(ctx, metrics.NewTag("event_type", event.GetType()))...,
508+
)
509+
c.logger.Debugw("announced validation event",
510+
"queue", request.Queue,
511+
"request_id", request.ID,
512+
"uri", request.URI,
513+
"event_type", event.GetType(),
514+
"event_id", event.GetId(),
515+
)
516+
return nil
517+
}
518+
477519
// Name returns the controller name for logging and metrics.
478520
func (c *Controller) Name() string {
479521
return "process"

0 commit comments

Comments
 (0)