Skip to content

Commit affcd4f

Browse files
authored
Merge branch 'main' into mnoah1/stovepipe-periodic-metrics
2 parents 2a4bf68 + 88c8f78 commit affcd4f

59 files changed

Lines changed: 2218 additions & 128 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

doc/rfc/stovepipe/steps/buildsignal.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,24 @@ Per `platform/errs`'s non-retryable-by-default rule (see [platform/errs/README.m
122122

123123
| Failure | Disposition | Why |
124124
|---|---|---|
125-
| `Status` call | raw error; classifier decides | Deliberately left open rather than fixed either way — runner timeout/connection is transient, "runner not deployed for this queue" is not, and only a backend classifier can tell them apart. |
125+
| `Status` call | raw error; classifier decides | Deliberately left open rather than fixed either way — runner timeout/connection is transient, "runner not deployed for this queue" is not, and only a backend classifier can tell them apart. **This means the `BuildRunner` backend has to classify**: an unclassified transport or HTTP error gets the non-retryable default, so one proxy blip ends the poll chain (see below). |
126126
| `Update` CAS conflict (`ErrVersionMismatch`) | declaration-level retryable | A concurrent (redelivered) writer moved the row; reload and re-check converges. |
127127

128128
`Build`/`Request` not found (`storage.ErrNotFound`) are **not** in this table: storage is required to be read-after-write consistent (see [storage README](stovepipe/extension/storage/README.md)), so a miss here is already the correct default (non-retryable, straight to DLQ) rather than a departure worth overriding.
129129

130130
Everything else — factory lookup, an `Update` store error other than a CAS conflict, and the `record` publish — is returned raw with no override, because the default is already correct: a queue with no registered runner is a config error, and storage/queue failures dead-letter and let DLQ reconciliation recover. The poll loop itself no longer has a publish to fail: holding is a local outcome, and a failed postpone write in the framework lapses into a normal visibility-timeout redelivery, so the loop's liveness never rides on an enqueue succeeding.
131131

132+
### What it costs when a backend does not classify `Status` errors
133+
134+
Leaving `Status` to the classifier only works if the backend classifies. A `BuildRunner` whose transport returns plain `fmt.Errorf` values gets the non-retryable default, and here that default is expensive: dead-lettering ends the *only* poll chain for a build that is still running, and the request keeps holding one of the queue's `in_flight_count` build slots until reconciliation gives it back. A single `502` from a proxy in front of the build API then looks exactly like "this build can never be polled".
135+
136+
Two things keep a blip from stalling a queue, and a backend needs both:
137+
138+
- **The backend classifies its own failures.** Transport errors and 5xx/429/408 responses are `errs.NewRetryableDependencyError`. A 4xx about the request itself — unknown build, forbidden — is `errs.NewDependencyError`. Only the layer that sees the status code can tell these apart, which is why the table above leaves the call to it.
139+
- **The retry budget is worth something.** Retryable means nack, and a nacked message comes back on the next poll, so `Retry.MaxAttempts` counts attempts rather than time — the default three are spent in a few hundred milliseconds. Raising `MaxAttempts` on this subscription buys a little more, but each attempt is another request at a dependency that is already failing, so it does not stretch to cover a proxy restart. Until nacks are spaced by the configured retry backoff, it is the reconciler below rather than the retry budget that keeps a longer outage from costing the queue a slot.
140+
141+
When the budget does run out the message dead-letters, and the buildsignal DLQ reconciler (`stovepipe/controller/dlq/buildsignal.go`) is what makes that recoverable: it maps the build back to its request, releases the slot, and marks the request `failed`. A deployment that registers the primary consumers but not that reconciler has no fail-closed path for this stage, and loses a slot for good every time this happens.
142+
132143
## Idempotency
133144

134145
Every branch is safe under at-least-once redelivery:

doc/rfc/submitqueue/speculation-generator-best-first.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ The batch being built is written before its assumptions. For example, `C [A succ
4949

5050
`Generate` receives the queue's live batches as a snapshot and takes it as given. A well-formed snapshot carries unique, non-empty batch IDs, includes every batch a head's direct dependencies reference, and gives no head an empty, duplicate, or self dependency. Those are preconditions the caller owns, established where the snapshot is assembled. The generator does not re-check them: it is on the hot path of every run, the checks it could make are the ones an assembled-correctly snapshot can never fail, and paying for them here only spreads the same contract across two places. A malformed snapshot yields undefined candidates rather than an error.
5151

52-
A score that is not a probability is the one bad input the generator absorbs, because it arrives from the injected scorer rather than from the caller and there is no earlier point that could catch it. A score outside `[0, 1]`, or `NaN`, is replaced with a default of 0.95 — optimistic on purpose, so a dependency nobody could estimate keeps its head's preferred path near the front instead of burying it or failing the whole run on one number. Any deliberate defaulting still belongs to the scorer implementation, which knows what information it does and does not have; this is only the floor under it.
52+
A dependency the generator cannot price is the one bad input it absorbs, because it arrives from the injected scorer rather than from the caller and there is no earlier point that could catch it. Three cases take the same 0.95 default: a score outside `[0, 1]` or `NaN`, a scorer call that returned an error, and a dependency the snapshot never carried. The default is optimistic on purpose, so a dependency nobody could estimate keeps its head's preferred path near the front instead of burying it or failing the whole run on one number — and failing the whole run is the real hazard, because `Generate` seeds the heap for every head at once, so one unpriceable dependency would otherwise cost the queue every candidate it had. A batch absent from the snapshot is never passed to the scorer at all: it would resolve to a zero-valued batch belonging to no queue, so scoring it would price some other batch entirely or fail on the empty queue name. Any deliberate defaulting still belongs to the scorer implementation, which knows what information it does and does not have; this is only the floor under it.
5353

5454
## Step 1: `Generate` prepares each head
5555

@@ -447,6 +447,7 @@ The ordering stays the same. `CandidatePath.RankingScore` contains this logarith
447447
- `Succeeded` fixes an assumption to succeeds.
448448
- `Failed` or `Cancelled` fixes an assumption to fails.
449449
- `Cancelling` remains undecided because cancellation may lose a race with completion.
450+
- `Merging` also remains undecided, because a merge can fail. It is tempting to treat it as committed to landing and skip the scorer call, but that puts a state-specific policy inside the search: whether a path betting against a merging batch is worth funding is a question of price, and price belongs to the scorer. The allocator draws the same line — "no batch state enters this decision" — and the generator holds it too. Nothing is lost by staying open, because a head can never merge ahead of a dependency it took a position on (see [speculation.md](speculation.md)); the cost of an unlikely path is budget, which is the allocator's to ration.
450451
- A fixed assumption stays in the returned path but contributes probability 1 and has no flip.
451452
- A shared dependency is scored once per run.
452453

platform/base/failure/BUILD.bazel

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["failure.go"],
6+
importpath = "github.com/uber/submitqueue/platform/base/failure",
7+
visibility = ["//visibility:public"],
8+
)
9+
10+
go_test(
11+
name = "go_default_test",
12+
srcs = ["failure_test.go"],
13+
embed = [":go_default_library"],
14+
deps = [
15+
"@com_github_stretchr_testify//assert:go_default_library",
16+
"@com_github_stretchr_testify//require:go_default_library",
17+
],
18+
)

platform/base/failure/failure.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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 failure holds the shared description of why processing failed: a
16+
// human-readable message, the entities the failure is about, and free-form
17+
// detail. It is the vocabulary a producer of a failure and a consumer of it
18+
// share when they are separated by a queue, so the consumer reads fields
19+
// rather than parsing prose.
20+
//
21+
// The package is deliberately domain-agnostic. It says a failure has subjects
22+
// and what shape a subject is; which subject types exist is a domain's own
23+
// business.
24+
package failure
25+
26+
import "encoding/json"
27+
28+
// Subject names one entity a failure is about.
29+
//
30+
// Its purpose is attribution: a consumer reconciling a failure has to know
31+
// what to act on, and the entity named on the message that failed is not
32+
// always the entity at fault — a job that reads many records can fail because
33+
// of any of them, or because of none of them individually.
34+
type Subject struct {
35+
// Type labels what kind of entity ID names, e.g. "batch" or "queue".
36+
// Values are chosen by the domain that raises the failure; this package
37+
// neither defines nor validates them. Empty means the type is unknown.
38+
Type string `json:"type"`
39+
// ID identifies the entity within its type. Opaque here: no format is
40+
// assumed and none is parsed.
41+
ID string `json:"id"`
42+
}
43+
44+
// Failure describes why processing failed.
45+
//
46+
// A failure is always about something. When no single record is at fault, the
47+
// subject is the wider thing that is — the queue, the tenant, the job — rather
48+
// than an empty list. That keeps absence from carrying meaning: no subjects at
49+
// all means the failure is *unattributed*, which is a genuine third state
50+
// (nothing recorded one, or the record predates attribution) and not a claim
51+
// that nothing was to blame.
52+
type Failure struct {
53+
// Message is the human-readable reason, typically an error's text. It is
54+
// the one field always present, and the one a person reads first.
55+
Message string `json:"-"`
56+
// Subjects are the entities this failure is about, in no significant
57+
// order. Empty means unattributed — see the type comment.
58+
Subjects []Subject `json:"subjects,omitempty"`
59+
// Detail is free-form structured context: whatever the producer knows that
60+
// does not fit the message. Values survive a JSON round trip, so numbers
61+
// come back as float64 regardless of what went in.
62+
Detail map[string]any `json:"detail,omitempty"`
63+
}
64+
65+
// New builds a Failure with a message and the subjects it is about.
66+
func New(message string, subjects ...Subject) Failure {
67+
return Failure{Message: message, Subjects: subjects}
68+
}
69+
70+
// IDsOfType returns the IDs of every subject with the given type, in the order
71+
// they appear. The result is empty when the failure names no such subject,
72+
// which is how a consumer asks "is this about one of mine?" without inspecting
73+
// the slice itself.
74+
func (f Failure) IDsOfType(subjectType string) []string {
75+
var ids []string
76+
for _, s := range f.Subjects {
77+
if s.Type == subjectType {
78+
ids = append(ids, s.ID)
79+
}
80+
}
81+
return ids
82+
}
83+
84+
// Encode returns the JSON encoding of the structured half of f — its subjects
85+
// and detail — or nil when there is no structure to store.
86+
//
87+
// Message is deliberately excluded. It travels as plain text alongside this
88+
// blob so that it stays legible to anything reading the underlying store
89+
// directly, and so decoding never has to guess whether a stored string is an
90+
// encoded failure or a message that merely looks like one.
91+
func Encode(f Failure) ([]byte, error) {
92+
if len(f.Subjects) == 0 && len(f.Detail) == 0 {
93+
return nil, nil
94+
}
95+
return json.Marshal(f)
96+
}
97+
98+
// Decode parses the structured half produced by Encode. Empty input yields the
99+
// zero Failure, which is how an unattributed failure reads.
100+
//
101+
// The returned Message is always empty: the caller holds it separately and
102+
// fills it in.
103+
func Decode(data []byte) (Failure, error) {
104+
if len(data) == 0 {
105+
return Failure{}, nil
106+
}
107+
var f Failure
108+
if err := json.Unmarshal(data, &f); err != nil {
109+
return Failure{}, err
110+
}
111+
return f, nil
112+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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 failure
16+
17+
import (
18+
"testing"
19+
20+
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
22+
)
23+
24+
func TestRoundTrip(t *testing.T) {
25+
tests := []struct {
26+
name string
27+
in Failure
28+
want Failure
29+
}{
30+
{
31+
name: "subjects and detail",
32+
in: New("speculator failed", Subject{Type: "queue", ID: "test-queue"}).
33+
withDetail(map[string]any{"stage": "ask"}),
34+
want: Failure{
35+
Subjects: []Subject{{Type: "queue", ID: "test-queue"}},
36+
Detail: map[string]any{"stage": "ask"},
37+
},
38+
},
39+
{
40+
name: "several subjects keep their order",
41+
in: New("two at fault", Subject{Type: "batch", ID: "q/batch/2"}, Subject{Type: "batch", ID: "q/batch/1"}),
42+
want: Failure{Subjects: []Subject{{Type: "batch", ID: "q/batch/2"}, {Type: "batch", ID: "q/batch/1"}}},
43+
},
44+
{
45+
name: "nested detail survives",
46+
in: Failure{Detail: map[string]any{"path": map[string]any{"id": "abc"}}},
47+
want: Failure{Detail: map[string]any{"path": map[string]any{"id": "abc"}}},
48+
},
49+
}
50+
51+
for _, tt := range tests {
52+
t.Run(tt.name, func(t *testing.T) {
53+
encoded, err := Encode(tt.in)
54+
require.NoError(t, err)
55+
require.NotEmpty(t, encoded)
56+
57+
got, err := Decode(encoded)
58+
require.NoError(t, err)
59+
assert.Equal(t, tt.want, got)
60+
})
61+
}
62+
}
63+
64+
// The message is carried outside the blob so that whatever stores it keeps a
65+
// legible column, and so decoding never has to tell an encoded failure apart
66+
// from a message that happens to look like one.
67+
func TestEncodeOmitsMessage(t *testing.T) {
68+
encoded, err := Encode(New("boom", Subject{Type: "batch", ID: "q/batch/1"}))
69+
require.NoError(t, err)
70+
assert.NotContains(t, string(encoded), "boom")
71+
72+
got, err := Decode(encoded)
73+
require.NoError(t, err)
74+
assert.Empty(t, got.Message)
75+
assert.Equal(t, []Subject{{Type: "batch", ID: "q/batch/1"}}, got.Subjects)
76+
}
77+
78+
// Nothing structured means nothing to store, which is what lets a caller treat
79+
// an absent blob as "unattributed" without a sentinel.
80+
func TestEncodeNothingStructured(t *testing.T) {
81+
encoded, err := Encode(New("just a message"))
82+
require.NoError(t, err)
83+
assert.Nil(t, encoded)
84+
}
85+
86+
func TestDecodeEmpty(t *testing.T) {
87+
got, err := Decode(nil)
88+
require.NoError(t, err)
89+
assert.Equal(t, Failure{}, got)
90+
}
91+
92+
func TestDecodeMalformed(t *testing.T) {
93+
_, err := Decode([]byte("not json"))
94+
assert.Error(t, err)
95+
}
96+
97+
// Detail goes through encoding/json, so every number returns as a float64
98+
// whatever its Go type going in. Pinned because a caller that stores an int64
99+
// and reads it back expecting one would otherwise find out at runtime.
100+
func TestDetailNumbersDecodeAsFloat64(t *testing.T) {
101+
encoded, err := Encode(Failure{Detail: map[string]any{"attempt": int64(3)}})
102+
require.NoError(t, err)
103+
104+
got, err := Decode(encoded)
105+
require.NoError(t, err)
106+
assert.Equal(t, float64(3), got.Detail["attempt"])
107+
}
108+
109+
func TestIDsOfType(t *testing.T) {
110+
f := New("mixed",
111+
Subject{Type: "batch", ID: "q/batch/1"},
112+
Subject{Type: "queue", ID: "q"},
113+
Subject{Type: "batch", ID: "q/batch/2"},
114+
)
115+
116+
assert.Equal(t, []string{"q/batch/1", "q/batch/2"}, f.IDsOfType("batch"))
117+
assert.Equal(t, []string{"q"}, f.IDsOfType("queue"))
118+
assert.Empty(t, f.IDsOfType("request"))
119+
assert.Empty(t, Failure{}.IDsOfType("batch"))
120+
}
121+
122+
// withDetail keeps the table above readable; New covers message and subjects,
123+
// which is what most callers set.
124+
func (f Failure) withDetail(detail map[string]any) Failure {
125+
f.Detail = detail
126+
return f
127+
}

platform/consumer/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ go_library(
1010
importpath = "github.com/uber/submitqueue/platform/consumer",
1111
visibility = ["//visibility:public"],
1212
deps = [
13+
"//platform/base/failure:go_default_library",
1314
"//platform/base/messagequeue:go_default_library",
1415
"//platform/errs:go_default_library",
1516
"//platform/extension/consumergate:go_default_library",
@@ -28,6 +29,7 @@ go_test(
2829
],
2930
embed = [":go_default_library"],
3031
deps = [
32+
"//platform/base/failure:go_default_library",
3133
"//platform/base/messagequeue:go_default_library",
3234
"//platform/errs:go_default_library",
3335
"//platform/extension/consumergate:go_default_library",

platform/consumer/consumer.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,12 @@ func (m *consumer) processDelivery(ctx context.Context, controller Controller, d
424424
// cancelled by the processing context during shutdown.
425425
isCanceled := errors.Is(err, context.Canceled)
426426

427+
// Whatever the controller attributed the failure to, plus the error's
428+
// own text as the message. A controller that attributed nothing yields
429+
// the message alone, which is what every caller sent before failures
430+
// carried structure.
431+
controllerFailure := errs.Attribution(err)
432+
427433
// Check if the error is non-retryable (poison pill message)
428434
if !errs.IsRetryable(err) {
429435
m.logger.Errorw("non-retryable controller error, rejecting message",
@@ -438,7 +444,7 @@ func (m *consumer) processDelivery(ctx context.Context, controller Controller, d
438444

439445
// Reject moves to DLQ (or acks if DLQ disabled)
440446
rejectOp := metrics.Begin(controllerScope, "reject", metrics.StorageLatencyBuckets)
441-
rejectErr := delivery.Reject(ctx, err.Error())
447+
rejectErr := delivery.Reject(ctx, controllerFailure)
442448
rejectOp.Complete(rejectErr)
443449
if rejectErr != nil {
444450
m.logger.Errorw("failed to reject non-retryable message",
@@ -468,9 +474,11 @@ func (m *consumer) processDelivery(ctx context.Context, controller Controller, d
468474
"elapsed_ms", elapsed.Milliseconds(),
469475
)
470476

471-
// Nack requeues immediately - the visibility timeout spaces retries
477+
// Nack requeues immediately - the visibility timeout spaces retries.
478+
// The failure travels with it so that the attempt which finally spends
479+
// the retry budget can dead-letter saying why.
472480
nackOp := metrics.Begin(controllerScope, "nack", metrics.StorageLatencyBuckets)
473-
nackErr := delivery.Nack(ctx)
481+
nackErr := delivery.Nack(ctx, controllerFailure)
474482
nackOp.Complete(nackErr)
475483
if nackErr != nil {
476484
m.logger.Errorw("failed to nack message",

0 commit comments

Comments
 (0)