Skip to content

Commit b5fc4ca

Browse files
committed
feat(stovepipe): Add fact degree lookup table
1 parent 6ea7eb7 commit b5fc4ca

22 files changed

Lines changed: 682 additions & 21 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
@@ -7,6 +7,7 @@ go_library(
77
"build_issue.go",
88
"ingest.go",
99
"list.go",
10+
"project_fact_by_degree.go",
1011
"project_status.go",
1112
"queue.go",
1213
"queue_config.go",
@@ -31,6 +32,7 @@ go_test(
3132
name = "go_default_test",
3233
srcs = [
3334
"build_test.go",
35+
"project_fact_by_degree_test.go",
3436
"queue_state_test.go",
3537
"request_id_test.go",
3638
"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
@@ -4,6 +4,7 @@ go_library(
44
name = "go_default_library",
55
srcs = [
66
"build_store.go",
7+
"project_fact_by_degree_store.go",
78
"queue_log_store.go",
89
"queue_store.go",
910
"queue_summary_store.go",

‎stovepipe/extension/storage/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@ Same design space as [`submitqueue/extension/storage`](../../../submitqueue/exte
2323
`RequestAcceptanceStore` is the ordered lookup mapping for List: immutable `(queue, accepted_at_ms, request_id)` keys with bounded primary-key range scans, not a secondary index or a second status projection. The request-log materializer ensures each known acceptance has a mapping after summary persistence, including retries where the summary is unchanged. Unknown acceptance times are not mapped; historical backfill is separate.
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).
26+
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.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go_library(
44
name = "go_default_library",
55
srcs = [
66
"build_store_mock.go",
7+
"project_fact_by_degree_store_mock.go",
78
"queue_log_store_mock.go",
89
"queue_store_mock.go",
910
"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)