Skip to content

Commit 772b4f8

Browse files
authored
test(stovepipe): add build-slow marker to the fake build runner (#464)
## Summary ### Why? The fake build runner never reports a non-terminal status — `Status` decides purely from the build id and always returns succeeded, failed, or an error. That means no integration or e2e stack can reach `buildsignal`'s reschedule branch, so the entire poll loop is exercised only by unit tests with mocked publishers. A defect in the reschedule path is invisible above the unit layer. ### What? Adds a `build-slow` marker. `Trigger` encodes a ready-at wall-clock instant into the build id (`fake-build-slow-<readyAtMs>-<suffix>`) and `Status` reports `running` until that instant passes, then `succeeded`. Encoding the deadline in the id preserves the fake's stateless, decide-purely-from-the-id property, so `Trigger` and `Status` can still live in different processes. The window is a `slowBuildDuration` field on the runner, defaulted in `New()`, so tests construct a runner with their own value instead of mutating shared state. Also widens `marker()` to end a token at `/` as well as `&` and `#`. The fake SourceControl builds head URIs as `git://<queue>/HEAD`, so a marker injected through a queue name sits mid-URI and would otherwise be read as `build-slow/HEAD`. Existing markers sit at the end of a URI and are unaffected. No pipeline behaviour changes; this only makes a previously unreachable path reachable in tests. ## Test Plan ✅ `bazel test //stovepipe/...` — covers `build-slow` reporting running then succeeded, the no-deadline fallback, and `marker()` against trailing path segments, query separators, and end-of-URI. ## Stack 1. @ #464 1. #465 1. #466 1. #467 1. #468 1. #469
1 parent 0223d89 commit 772b4f8

2 files changed

Lines changed: 104 additions & 6 deletions

File tree

stovepipe/extension/buildrunner/fake/fake.go

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@
1414

1515
// Package fake provides a buildrunner.BuildRunner whose outcome is driven by the
1616
// triggered head URI. With no marker every build immediately succeeds, behaving
17-
// as a best-case stub for local-stack/e2e wiring. Failures are injected by
17+
// as a best-case stub for local-stack/e2e wiring. Other behaviors are injected by
1818
// embedding a marker token in headURI of the form "buildrunner-fake=<token>":
1919
//
2020
// buildrunner-fake=trigger-error -> Trigger returns a non-nil error
2121
// buildrunner-fake=build-fail -> Status reports BuildStatusFailed
2222
// buildrunner-fake=build-error -> Status returns a non-nil error
23+
// buildrunner-fake=build-slow -> Status reports BuildStatusRunning for a
24+
// short window after Trigger, then succeeds
2325
//
2426
// The runner is stateless: Trigger encodes the desired terminal outcome into the
2527
// returned BuildID, and Status decides the result purely from the BuildID it is
@@ -35,12 +37,20 @@ import (
3537
"crypto/rand"
3638
"encoding/hex"
3739
"fmt"
40+
"strconv"
3841
"strings"
42+
"time"
3943

4044
"github.com/uber/submitqueue/stovepipe/entity"
4145
"github.com/uber/submitqueue/stovepipe/extension/buildrunner"
4246
)
4347

48+
// defaultSlowBuildDuration is how long a build-slow build reports
49+
// BuildStatusRunning before succeeding. It must be long enough for the caller's
50+
// poll loop to observe at least one non-terminal status; tests that need a
51+
// different window construct a runner with their own value.
52+
const defaultSlowBuildDuration = 3 * time.Second
53+
4454
// markerPrefix introduces a marker token in headURI: "buildrunner-fake=<token>".
4555
const markerPrefix = "buildrunner-fake="
4656

@@ -49,6 +59,7 @@ const (
4959
tokenTriggerError = "trigger-error"
5060
tokenFail = "build-fail"
5161
tokenError = "build-error"
62+
tokenSlow = "build-slow"
5263
)
5364

5465
// outcomeOK is the BuildID outcome segment for a build that should succeed.
@@ -59,12 +70,17 @@ const outcomeOK = "ok"
5970
// state: the outcome is encoded in the BuildID at Trigger and read back out at
6071
// Status. Uniqueness comes from a random suffix per id, so it needs no shared
6172
// counter and never collides across instances or processes.
62-
type runner struct{}
73+
type runner struct {
74+
// slowBuildDuration is how long a build-slow build reports running before
75+
// it succeeds. Configuration, not per-build state: it is read at Trigger to
76+
// compute the deadline baked into the id, never mutated.
77+
slowBuildDuration time.Duration
78+
}
6379

6480
// New returns a buildrunner.BuildRunner that defaults to succeeding and honors
6581
// marker tokens embedded in the triggered headURI.
6682
func New() buildrunner.BuildRunner {
67-
return runner{}
83+
return runner{slowBuildDuration: defaultSlowBuildDuration}
6884
}
6985

7086
// Trigger fails when headURI carries the trigger-error marker; otherwise it
@@ -80,6 +96,10 @@ func (r runner) Trigger(_ context.Context, _, headURI string, _ entity.BuildMeta
8096
outcome = tokenFail
8197
case tokenError:
8298
outcome = tokenError
99+
case tokenSlow:
100+
// A slow build carries the wall-clock instant it becomes terminal, so Status
101+
// stays stateless: any instance, in any process, decodes the same deadline.
102+
outcome = fmt.Sprintf("%s-%d", tokenSlow, time.Now().Add(r.slowBuildDuration).UnixMilli())
83103
}
84104

85105
// Encode the outcome in the id (e.g. "fake-build-fail-a1b2c3d4") so Status is
@@ -101,6 +121,11 @@ func (r runner) Status(_ context.Context, buildID entity.BuildID) (entity.BuildS
101121
return entity.BuildStatusUnknown, nil, fmt.Errorf("fake: marked build error")
102122
case strings.Contains(buildID.ID, tokenFail):
103123
return entity.BuildStatusFailed, nil, nil
124+
case strings.Contains(buildID.ID, tokenSlow):
125+
if readyAt, ok := slowReadyAt(buildID.ID); ok && time.Now().UnixMilli() < readyAt {
126+
return entity.BuildStatusRunning, nil, nil
127+
}
128+
return entity.BuildStatusSucceeded, nil, nil
104129
default:
105130
return entity.BuildStatusSucceeded, nil, nil
106131
}
@@ -112,19 +137,38 @@ func (r runner) Cancel(_ context.Context, _ entity.BuildID) error {
112137
}
113138

114139
// marker returns the marker token embedded in uri, or "" if none is present.
115-
// The token ends at the first "&" or "#" delimiter, so a marker may sit among
116-
// other query parameters or a fragment.
140+
// The token ends at the first "&", "#", or "/" delimiter, so a marker may sit
141+
// among other query parameters, before a fragment, or ahead of a further path
142+
// segment (as when a URI is built as "git://<queue>/HEAD" and the marker rides
143+
// in on the queue name).
117144
func marker(uri string) string {
118145
_, rest, found := strings.Cut(uri, markerPrefix)
119146
if !found {
120147
return ""
121148
}
122-
if i := strings.IndexAny(rest, "&#"); i >= 0 {
149+
if i := strings.IndexAny(rest, "&#/"); i >= 0 {
123150
rest = rest[:i]
124151
}
125152
return rest
126153
}
127154

155+
// slowReadyAt extracts the epoch-millisecond instant at which a build-slow build
156+
// becomes terminal, which Trigger encodes into the id as
157+
// "fake-build-slow-<readyAtMs>-<suffix>". It reports false when the id carries no
158+
// parsable deadline, in which case the caller treats the build as already terminal.
159+
func slowReadyAt(id string) (int64, bool) {
160+
_, rest, found := strings.Cut(id, tokenSlow+"-")
161+
if !found {
162+
return 0, false
163+
}
164+
digits, _, _ := strings.Cut(rest, "-")
165+
readyAt, err := strconv.ParseInt(digits, 10, 64)
166+
if err != nil {
167+
return 0, false
168+
}
169+
return readyAt, true
170+
}
171+
128172
// randomSuffix returns a short random hex string used to keep fake BuildIDs
129173
// globally unique. Hex digits never spell the outcome marker tokens, so the
130174
// suffix cannot interfere with Status decoding the outcome via substring match.

stovepipe/extension/buildrunner/fake/fake_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ package fake
1616

1717
import (
1818
"context"
19+
"fmt"
1920
"testing"
21+
"time"
2022

2123
"github.com/stretchr/testify/assert"
2224
"github.com/stretchr/testify/require"
@@ -110,3 +112,55 @@ func TestCancel_NoOp(t *testing.T) {
110112
err := New().Cancel(context.Background(), entity.BuildID{ID: "anything"})
111113
assert.NoError(t, err)
112114
}
115+
116+
// TestStatus_BuildSlowReportsRunningThenSucceeds covers the one marker that yields a
117+
// non-terminal status, which is what makes a caller's poll loop reachable in an
118+
// integration or e2e stack.
119+
func TestStatus_BuildSlowReportsRunningThenSucceeds(t *testing.T) {
120+
// A window long enough that the build is still running when Status is called.
121+
slow := runner{slowBuildDuration: 30 * time.Second}
122+
123+
id, err := slow.Trigger(context.Background(), "", "git://repo/ref/deadbeef?buildrunner-fake=build-slow", nil)
124+
require.NoError(t, err)
125+
126+
status, _, err := New().Status(context.Background(), id)
127+
require.NoError(t, err)
128+
assert.Equal(t, entity.BuildStatusRunning, status)
129+
130+
// An id whose deadline has already passed reports the terminal outcome. Encoding
131+
// the deadline in the id is what keeps Status stateless across instances.
132+
elapsed := entity.BuildID{ID: fmt.Sprintf("fake-build-slow-%d-abcd1234", time.Now().UnixMilli()-1)}
133+
status, _, err = New().Status(context.Background(), elapsed)
134+
require.NoError(t, err)
135+
assert.Equal(t, entity.BuildStatusSucceeded, status)
136+
}
137+
138+
// TestStatus_BuildSlowWithoutDeadlineSucceeds pins the fallback: an id carrying the
139+
// marker but no parsable deadline is treated as already terminal rather than polling
140+
// forever.
141+
func TestStatus_BuildSlowWithoutDeadlineSucceeds(t *testing.T) {
142+
status, _, err := New().Status(context.Background(), entity.BuildID{ID: "fake-build-slow-nodeadline"})
143+
require.NoError(t, err)
144+
assert.Equal(t, entity.BuildStatusSucceeded, status)
145+
}
146+
147+
// TestMarker_StopsAtPathSegment covers a marker that arrives mid-URI rather than at the
148+
// end, as it does when the head URI is built as "git://<queue>/HEAD" and the marker
149+
// rides in on the queue name.
150+
func TestMarker_StopsAtPathSegment(t *testing.T) {
151+
tests := []struct {
152+
name string
153+
uri string
154+
want string
155+
}{
156+
{name: "trailing path segment", uri: "git://repo/main?buildrunner-fake=build-slow/HEAD", want: "build-slow"},
157+
{name: "end of uri", uri: "git://repo/ref/deadbeef?buildrunner-fake=build-fail", want: "build-fail"},
158+
{name: "query separator", uri: "git://repo/ref?buildrunner-fake=build-fail&other=1", want: "build-fail"},
159+
{name: "no marker", uri: "git://repo/ref/deadbeef", want: ""},
160+
}
161+
for _, tt := range tests {
162+
t.Run(tt.name, func(t *testing.T) {
163+
assert.Equal(t, tt.want, marker(tt.uri))
164+
})
165+
}
166+
}

0 commit comments

Comments
 (0)