Skip to content

Commit f87bf65

Browse files
committed
feat(stovepipe): Add project ordering index
1 parent 6ea7eb7 commit f87bf65

23 files changed

Lines changed: 682 additions & 21 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Project validation ordering index
2+
3+
The project validation index is an immutable mapping from `(queue, URI, degree_order, project)` to the fact keyed by `(queue, URI, project)`. It contains no duplicate validation payload. Its primary key supports bounded range reads in descending breakage degree and ascending bytewise project ID, without sorting a non-key attribute or scanning every fact.
4+
5+
`degree_order` reverses nonnegative IEEE-754 degree bit patterns into an ascending integer key without rounding. Positive and negative zero share one key; invalid degrees and whole-repository facts cannot produce mappings. Binary identifiers preserve bytewise tie order.
6+
7+
## Persistence and repair
8+
9+
The record controller persists the winning immutable fact before ensuring its mapping. Redelivery repairs missing mappings even when the fact already exists, and derives their keys from the stored degree rather than a losing candidate. Project-recording logs and downstream terminal notifications follow successful mapping writes. No cross-store transaction is required.
10+
11+
## Rollout
12+
13+
The ordering table is additive; the original fact identity and primary key are unchanged. Install the table before deploying index writers, then adopt index-based readers after the writers are deployed.
14+
15+
The table starts empty. There is no historical backfill or fallback scan: older requests may have empty project listings, and requests spanning the rollout may have partial listings. Existing facts remain available through explicit project lookups. This historical-coverage limitation is intentional; no data migration or compatibility state is required.
16+
17+
Keep the additive table when reverting application adoption. Facts recorded while index writers are disabled remain outside index-based listings unless the normal record step processes them again.

‎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.NewProjectValidationIndex(fact)
212+
if err != nil {
213+
return err
214+
}
215+
216+
if err := store.GetProjectValidationIndexStore().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.MockProjectValidationIndexStore
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.NewMockProjectValidationIndexStore(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().GetProjectValidationIndexStore().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.ProjectValidationIndex
409+
m.indexStore.EXPECT().Create(gomock.Any(), gomock.Any()).
410+
DoAndReturn(func(_ context.Context, entry entity.ProjectValidationIndex) 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.NewProjectValidationIndex(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.NewProjectValidationIndex(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
"project_status.go",
11+
"project_validation_index.go",
1112
"queue.go",
1213
"queue_config.go",
1314
"queue_log.go",
@@ -31,6 +32,7 @@ go_test(
3132
name = "go_default_test",
3233
srcs = [
3334
"build_test.go",
35+
"project_validation_index_test.go",
3436
"queue_state_test.go",
3537
"request_id_test.go",
3638
"request_log_test.go",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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+
// ProjectValidationIndex maps an ordered key to an immutable project fact within a queue.
23+
type ProjectValidationIndex struct {
24+
// URI is the commit containing the project result.
25+
URI string
26+
// DegreeOrder is the ascending integer key for descending breakage degree.
27+
DegreeOrder int64
28+
// Project identifies the fact and breaks equal-degree ties in ascending bytewise order.
29+
Project string
30+
}
31+
32+
// NewProjectValidationIndex derives an exact ordering key from a named project's fact.
33+
// Invalid degrees, empty URIs and whole-repository facts return an error.
34+
func NewProjectValidationIndex(fact ValidationFact) (ProjectValidationIndex, error) {
35+
if fact.URI == "" || fact.Project == "" || math.IsNaN(fact.Degree) || fact.Degree < DegreeGreen || fact.Degree > DegreeBroken {
36+
return ProjectValidationIndex{}, fmt.Errorf("project validation index requires a URI, project and degree in [0, 1]")
37+
}
38+
degree := fact.Degree
39+
if degree == 0 {
40+
degree = 0 // Canonicalize negative zero to the same key as positive zero.
41+
}
42+
// Nonnegative IEEE-754 bit patterns increase with degree; integer subtraction reverses that order without rounding.
43+
order := int64(math.Float64bits(DegreeBroken) - math.Float64bits(degree))
44+
return ProjectValidationIndex{URI: fact.URI, DegreeOrder: order, Project: fact.Project}, nil
45+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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 TestProjectValidationIndexOrdersDegreesExactly(t *testing.T) {
26+
degrees := []float64{1, math.Nextafter(1, 0), 0.75, math.Nextafter(0.5, 1), 0.5, math.SmallestNonzeroFloat64, 0}
27+
var previous ProjectValidationIndex
28+
for i, degree := range degrees {
29+
entry, err := NewProjectValidationIndex(ValidationFact{URI: "commit", Project: "project", Degree: degree})
30+
require.NoError(t, err)
31+
if i > 0 {
32+
assert.Less(t, previous.DegreeOrder, entry.DegreeOrder)
33+
}
34+
previous = entry
35+
}
36+
negativeZero, err := NewProjectValidationIndex(ValidationFact{URI: "commit", Project: "project", Degree: math.Copysign(0, -1)})
37+
require.NoError(t, err)
38+
assert.Equal(t, previous, negativeZero)
39+
}
40+
41+
func TestProjectValidationIndexRejectsInvalidFacts(t *testing.T) {
42+
for _, fact := range []ValidationFact{
43+
{Project: "p"}, {URI: "commit"},
44+
{URI: "commit", Project: "p", Degree: math.NaN()},
45+
{URI: "commit", Project: "p", Degree: math.Inf(1)},
46+
{URI: "commit", Project: "p", Degree: -0.1},
47+
{URI: "commit", Project: "p", Degree: 1.1},
48+
} {
49+
_, err := NewProjectValidationIndex(fact)
50+
require.Error(t, err)
51+
}
52+
}

‎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_validation_index_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+
`ProjectValidationIndexStore` is the ordered lookup mapping for named project facts. Its immutable `(queue, URI, degree_order, project)` primary key encodes 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, including retries after partial writes. 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_validation_index_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_validation_index_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.

0 commit comments

Comments
 (0)