Skip to content

Commit a67eae2

Browse files
authored
feat(stovepipe): Add fact degree lookup table (#908)
## Summary **What**: - Maintain a degree-ordered lookup table for project validation facts and repair interrupted writes on retry. - Start the lookup table empty while keeping existing project listing and pagination unchanged. **Why**: - Prepare efficient severity-first reads without historical migration or backfill tooling. ## Test Plan - [x] Add unit tests. - [x] Verify database writes and unchanged service and browser pagination against real services. ## Revert Plan - Revert dependent readers first, then this PR. Leave the additive table in place; authoritative validation facts are unchanged. ## Issue Links - [CODEM-608](https://linear.app/uber/issue/CODEM-608/order-by-most-broken) ## Stack 1. #908 1. #903
1 parent 473ab2c commit a67eae2

22 files changed

Lines changed: 695 additions & 25 deletions

‎stovepipe/controller/record/record.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ func (c *Controller) recordProjectFacts(ctx context.Context, store storage.Stora
197197
return err
198198
}
199199
for _, result := range results {
200-
_, _, err := c.recordValidationFact(ctx, store.GetValidationFactStore(), entity.ValidationFact{
200+
fact, _, err := c.recordValidationFact(ctx, store.GetValidationFactStore(), entity.ValidationFact{
201201
URI: request.URI,
202202
Project: result.Project,
203203
Degree: result.Degree,
@@ -207,6 +207,15 @@ func (c *Controller) recordProjectFacts(ctx context.Context, store storage.Stora
207207
if err != nil {
208208
return err
209209
}
210+
211+
entry, err := entity.NewProjectFactByDegree(fact)
212+
if err != nil {
213+
return err
214+
}
215+
216+
if err := store.GetProjectFactByDegreeStore().Create(ctx, entry); err != nil && !errors.Is(err, storage.ErrAlreadyExists) {
217+
return fmt.Errorf("failed to index project fact for request %q and project %q: %w", request.ID, fact.Project, err)
218+
}
210219
}
211220
if len(results) == 0 {
212221
return nil

‎stovepipe/controller/record/record_test.go‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ type recordMocks struct {
7474
reqStore *storagemock.MockRequestStore
7575
queueStore *storagemock.MockQueueStore
7676
factStore *storagemock.MockValidationFactStore
77+
indexStore *storagemock.MockProjectFactByDegreeStore
7778
store *storagemock.MockStorage
7879
materializer *requestlogmock.MockMaterializer
7980
sourceControl *sourcecontrolmock.MockSourceControl
@@ -148,6 +149,7 @@ func newControllerForTopic(t *testing.T, ctrl *gomock.Controller, topicKey consu
148149
reqStore: storagemock.NewMockRequestStore(ctrl),
149150
queueStore: storagemock.NewMockQueueStore(ctrl),
150151
factStore: storagemock.NewMockValidationFactStore(ctrl),
152+
indexStore: storagemock.NewMockProjectFactByDegreeStore(ctrl),
151153
store: storagemock.NewMockStorage(ctrl),
152154
materializer: requestlogmock.NewMockMaterializer(ctrl),
153155
sourceControl: sourcecontrolmock.NewMockSourceControl(ctrl),
@@ -158,6 +160,7 @@ func newControllerForTopic(t *testing.T, ctrl *gomock.Controller, topicKey consu
158160
m.store.EXPECT().GetRequestStore().Return(m.reqStore).AnyTimes()
159161
m.store.EXPECT().GetQueueStore().Return(m.queueStore).AnyTimes()
160162
m.store.EXPECT().GetValidationFactStore().Return(m.factStore).AnyTimes()
163+
m.store.EXPECT().GetProjectFactByDegreeStore().Return(m.indexStore).AnyTimes()
161164
m.materializer.EXPECT().PersistLog(gomock.Any(), m.store, gomock.Any()).DoAndReturn(
162165
func(_ context.Context, _ storage.Storage, log entity.RequestLog) error {
163166
assert.Equal(t, entity.RequestEventValidationFactRecorded, log.Event)
@@ -402,6 +405,13 @@ func TestProcess_RecordsNamedProjectStatusResults(t *testing.T) {
402405
}).
403406
Times(3)
404407

408+
var indexes []entity.ProjectFactByDegree
409+
m.indexStore.EXPECT().Create(gomock.Any(), gomock.Any()).
410+
DoAndReturn(func(_ context.Context, entry entity.ProjectFactByDegree) error {
411+
indexes = append(indexes, entry)
412+
return nil
413+
}).Times(2)
414+
405415
var logs []entity.RequestLog
406416
eventMaterializer.EXPECT().PersistLog(gomock.Any(), m.store, gomock.Any()).
407417
DoAndReturn(func(_ context.Context, _ storage.Storage, log entity.RequestLog) error {
@@ -412,6 +422,12 @@ func TestProcess_RecordsNamedProjectStatusResults(t *testing.T) {
412422

413423
require.NoError(t, c.Process(queueContext(), delivery(t, ctrl, recordPayload(t, testID))))
414424
require.Len(t, facts, 3)
425+
require.Len(t, indexes, 2)
426+
for i, fact := range facts[1:] {
427+
entry, err := entity.NewProjectFactByDegree(fact)
428+
require.NoError(t, err)
429+
assert.Equal(t, entry, indexes[i])
430+
}
415431
assert.Equal(t, []string{"", "project-a", "project-b"}, []string{facts[0].Project, facts[1].Project, facts[2].Project})
416432
for _, fact := range facts {
417433
assert.Equal(t, testURI, fact.URI)
@@ -1217,3 +1233,43 @@ func TestProcess_QueueMismatchFails(t *testing.T) {
12171233

12181234
require.Error(t, c.Process(queueContext(), d))
12191235
}
1236+
1237+
func TestRecordProjectFactsRepairsIndexOnRetry(t *testing.T) {
1238+
for _, retryErr := range []error{nil, storage.ErrAlreadyExists} {
1239+
name := "index repaired"
1240+
if retryErr != nil {
1241+
name = "index already repaired"
1242+
}
1243+
t.Run(name, func(t *testing.T) {
1244+
ctrl := gomock.NewController(t)
1245+
c, m := newController(t, ctrl)
1246+
request := requestWithState(entity.RequestStateFailed)
1247+
resolverFactory := projectstatusmock.NewMockResolverFactory(ctrl)
1248+
resolver := projectstatusmock.NewMockResolver(ctrl)
1249+
c.projectStatusResolverFactory = resolverFactory
1250+
resolverFactory.EXPECT().For(projectstatus.ResolverConfig{QueueName: testQueue}).Return(resolver, nil).Times(2)
1251+
stored := entity.ValidationFact{URI: request.URI, Project: "p", Degree: 0.25, RequestID: request.ID, CreatedAt: 10}
1252+
entry, err := entity.NewProjectFactByDegree(stored)
1253+
require.NoError(t, err)
1254+
writeErr := errors.New("index write unavailable")
1255+
materializer := requestlogmock.NewMockMaterializer(ctrl)
1256+
c.materializer = materializer
1257+
1258+
gomock.InOrder(
1259+
resolver.EXPECT().Resolve(gomock.Any(), request, request.TerminalBuildID).Return([]projectstatus.Result{{Project: "p", Degree: 0.25}}, nil),
1260+
m.factStore.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil),
1261+
m.indexStore.EXPECT().Create(gomock.Any(), entry).Return(writeErr),
1262+
resolver.EXPECT().Resolve(gomock.Any(), request, request.TerminalBuildID).Return([]projectstatus.Result{{Project: "p", Degree: 0.75}}, nil),
1263+
m.factStore.EXPECT().Create(gomock.Any(), gomock.Any()).Return(storage.ErrAlreadyExists),
1264+
m.factStore.EXPECT().Get(gomock.Any(), request.URI, "p").Return(stored, nil),
1265+
m.indexStore.EXPECT().Create(gomock.Any(), entry).Return(retryErr),
1266+
materializer.EXPECT().PersistLog(gomock.Any(), m.store, gomock.Any()).DoAndReturn(func(_ context.Context, _ storage.Storage, log entity.RequestLog) error {
1267+
assert.Equal(t, entity.RequestEventProjectFactsRecorded, log.Event)
1268+
return nil
1269+
}),
1270+
)
1271+
require.ErrorIs(t, c.recordProjectFacts(queueContext(), m.store, request), writeErr)
1272+
require.NoError(t, c.recordProjectFacts(queueContext(), m.store, request))
1273+
})
1274+
}
1275+
}

‎stovepipe/entity/BUILD.bazel‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ go_library(
88
"ingest.go",
99
"list.go",
1010
"open_issue.go",
11+
"project_fact_by_degree.go",
1112
"project_status.go",
1213
"queue.go",
1314
"queue_config.go",
@@ -32,6 +33,7 @@ go_test(
3233
name = "go_default_test",
3334
srcs = [
3435
"build_test.go",
36+
"project_fact_by_degree_test.go",
3537
"queue_state_test.go",
3638
"request_id_test.go",
3739
"request_log_test.go",
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2026 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 entity
16+
17+
import (
18+
"fmt"
19+
"math"
20+
)
21+
22+
// ProjectFactByDegree maps an ordered key to an immutable project fact within a queue.
23+
type ProjectFactByDegree struct {
24+
// URI is the commit containing the project result.
25+
URI string
26+
// Degree is the breakage degree in [0, 1], ordered highest first.
27+
Degree float64
28+
// Project identifies the fact and breaks equal-degree ties in ascending bytewise order.
29+
Project string
30+
}
31+
32+
// NewProjectFactByDegree derives a degree lookup entry from a named project's fact.
33+
// Invalid degrees, empty URIs and whole-repository facts return an error.
34+
func NewProjectFactByDegree(fact ValidationFact) (ProjectFactByDegree, error) {
35+
if fact.URI == "" || fact.Project == "" || math.IsNaN(fact.Degree) || fact.Degree < DegreeGreen || fact.Degree > DegreeBroken {
36+
return ProjectFactByDegree{}, fmt.Errorf("project fact degree lookup requires a URI, project and degree in [0, 1]")
37+
}
38+
return ProjectFactByDegree{URI: fact.URI, Degree: fact.Degree, Project: fact.Project}, nil
39+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) 2026 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 entity
16+
17+
import (
18+
"math"
19+
"testing"
20+
21+
"github.com/stretchr/testify/assert"
22+
"github.com/stretchr/testify/require"
23+
)
24+
25+
func TestProjectFactByDegreePreservesDegree(t *testing.T) {
26+
for _, degree := range []float64{1, math.Nextafter(1, 0), 0.75, math.Nextafter(0.5, 1), 0.5, math.SmallestNonzeroFloat64, 0, math.Copysign(0, -1)} {
27+
entry, err := NewProjectFactByDegree(ValidationFact{URI: "commit", Project: "project", Degree: degree})
28+
require.NoError(t, err)
29+
assert.Equal(t, ProjectFactByDegree{URI: "commit", Project: "project", Degree: degree}, entry)
30+
}
31+
}
32+
33+
func TestProjectFactByDegreeRejectsInvalidFacts(t *testing.T) {
34+
for _, fact := range []ValidationFact{
35+
{Project: "p"}, {URI: "commit"},
36+
{URI: "commit", Project: "p", Degree: math.NaN()},
37+
{URI: "commit", Project: "p", Degree: math.Inf(1)},
38+
{URI: "commit", Project: "p", Degree: -0.1},
39+
{URI: "commit", Project: "p", Degree: 1.1},
40+
} {
41+
_, err := NewProjectFactByDegree(fact)
42+
require.Error(t, err)
43+
}
44+
}

‎stovepipe/extension/storage/BUILD.bazel‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ go_library(
66
"build_issue_store.go",
77
"build_store.go",
88
"open_issue_store.go",
9+
"project_fact_by_degree_store.go",
910
"queue_log_store.go",
1011
"queue_store.go",
1112
"queue_summary_store.go",

‎stovepipe/extension/storage/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Same design space as [`submitqueue/extension/storage`](../../../submitqueue/exte
2424

2525
`QueueStore` owns internal enablement alongside pipeline coordination. `QueueSummaryStore` persists the current read model projected from immutable `QueueLogStore` occurrences. Enablement sequences, operational row versions and projection versions are distinct. These stores use primary-key operations; version arithmetic, duplicate reconciliation and projection repair belong to business logic. See [queue state](../../../doc/rfc/stovepipe/queue-state.md).
2626

27+
`ProjectFactByDegreeStore` is the ordered lookup mapping for named project facts. Its immutable `(queue, URI, degree DESC, project ASC)` primary key stores the original degree and orders by descending degree and ascending bytewise project ID, so listing is a bounded key-range read. It holds no duplicate validation payload; readers hydrate facts by their original keys and verify the mapping. The record controller ensures mappings from persisted winning facts before recording project completion, including retries after partial writes. Create the table before deploying writers. The table starts empty: only results processed by index-aware writers receive mappings, with no historical population or read-time fallback.
28+
2729
## Build issue observations
2830

2931
`BuildIssueStore` accumulates immutable observations identified by build, task, and unit within the bound queue. The build ID is supplied separately from the detected issue. Repeated observations return `ErrAlreadyExists`, which callers may treat as successful persistence. Empty unit IDs represent task-wide observations.

‎stovepipe/extension/storage/mock/BUILD.bazel‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ go_library(
66
"build_issue_store_mock.go",
77
"build_store_mock.go",
88
"open_issue_store_mock.go",
9+
"project_fact_by_degree_store_mock.go",
910
"queue_log_store_mock.go",
1011
"queue_store_mock.go",
1112
"queue_summary_store_mock.go",

‎stovepipe/extension/storage/mock/project_fact_by_degree_store_mock.go‎

Lines changed: 71 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎stovepipe/extension/storage/mock/storage_mock.go‎

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)