Skip to content

Commit c0e7f96

Browse files
committed
feat(summary): project request logs from admission context
1 parent 78d7442 commit c0e7f96

16 files changed

Lines changed: 716 additions & 0 deletions

submitqueue/core/request/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go_library(
55
srcs = [
66
"log.go",
77
"request.go",
8+
"store.go",
89
],
910
importpath = "github.com/uber/submitqueue/submitqueue/core/request",
1011
visibility = ["//visibility:public"],
@@ -22,6 +23,7 @@ go_test(
2223
srcs = [
2324
"log_test.go",
2425
"request_test.go",
26+
"store_test.go",
2527
],
2628
embed = [":request"],
2729
deps = [

submitqueue/core/request/store.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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 request
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
"reflect"
22+
23+
"github.com/uber/submitqueue/submitqueue/entity"
24+
"github.com/uber/submitqueue/submitqueue/extension/storage"
25+
)
26+
27+
const maxSummaryProjectionAttempts = 8
28+
29+
// CreateContext creates immutable admission data and the initial List projection.
30+
func CreateContext(ctx context.Context, store storage.Storage, requestContext entity.RequestContext) error {
31+
if err := store.GetRequestContextStore().Create(ctx, requestContext); err != nil {
32+
if !errors.Is(err, storage.ErrAlreadyExists) {
33+
return fmt.Errorf("failed to create request context request_id=%s: %w", requestContext.RequestID, err)
34+
}
35+
existing, getErr := store.GetRequestContextStore().Get(ctx, requestContext.RequestID)
36+
if getErr != nil {
37+
return fmt.Errorf("failed to verify existing request context request_id=%s: %w", requestContext.RequestID, getErr)
38+
}
39+
if !equalRequestContext(existing, requestContext) {
40+
return fmt.Errorf("request context conflicts with existing immutable context request_id=%s", requestContext.RequestID)
41+
}
42+
}
43+
44+
summary := entity.RequestSummary{
45+
RequestID: requestContext.RequestID,
46+
Queue: requestContext.Queue,
47+
ChangeURIs: append([]string(nil), requestContext.ChangeURIs...),
48+
Status: entity.RequestStatusAccepted,
49+
Metadata: map[string]string{},
50+
StartedAtMs: requestContext.AdmittedAtMs,
51+
UpdatedAtMs: requestContext.AdmittedAtMs,
52+
StatusTimestampMs: requestContext.AdmittedAtMs,
53+
Version: 1,
54+
}
55+
if err := store.GetRequestSummaryStore().Create(ctx, summary); err != nil && !errors.Is(err, storage.ErrAlreadyExists) {
56+
return fmt.Errorf("failed to create request summary request_id=%s: %w", requestContext.RequestID, err)
57+
}
58+
return nil
59+
}
60+
61+
// PersistLog appends one immutable status event and updates its summary projection. A projection error is returned so queue delivery retries.
62+
func PersistLog(ctx context.Context, store storage.Storage, log entity.RequestLog) error {
63+
if err := store.GetRequestLogStore().Insert(ctx, log); err != nil {
64+
return fmt.Errorf("failed to insert request log request_id=%s: %w", log.RequestID, err)
65+
}
66+
if err := ProjectLog(ctx, store, log); err != nil {
67+
return fmt.Errorf("failed to project request log request_id=%s: %w", log.RequestID, err)
68+
}
69+
return nil
70+
}
71+
72+
// ProjectLog applies a status event to an existing summary using optimistic concurrency.
73+
func ProjectLog(ctx context.Context, store storage.Storage, log entity.RequestLog) error {
74+
requestContext, err := store.GetRequestContextStore().Get(ctx, log.RequestID)
75+
if err != nil {
76+
return fmt.Errorf("failed to get request context request_id=%s: %w", log.RequestID, err)
77+
}
78+
79+
for attempt := 0; attempt < maxSummaryProjectionAttempts; attempt++ {
80+
existing, err := store.GetRequestSummaryStore().Get(ctx, requestContext.Queue, log.RequestID)
81+
if err != nil {
82+
return fmt.Errorf("failed to get request summary request_id=%s: %w", log.RequestID, err)
83+
}
84+
next := MergeSummary(existing, log)
85+
newVersion := existing.Version + 1
86+
if err := store.GetRequestSummaryStore().Update(ctx, next, existing.Version, newVersion); err == nil {
87+
return nil
88+
} else if !errors.Is(err, storage.ErrVersionMismatch) {
89+
return fmt.Errorf("failed to update request summary request_id=%s: %w", log.RequestID, err)
90+
}
91+
}
92+
return fmt.Errorf("request summary projection did not converge request_id=%s: %w", log.RequestID, storage.ErrVersionMismatch)
93+
}
94+
95+
// MergeSummary returns the summary that results when log is reconciled against existing.
96+
func MergeSummary(existing entity.RequestSummary, log entity.RequestLog) entity.RequestSummary {
97+
next := existing
98+
if !shouldReplaceWinner(existing, log) {
99+
return next
100+
}
101+
next.Status = log.Status
102+
next.LastError = log.LastError
103+
next.Metadata = cloneMetadata(log.Metadata)
104+
next.UpdatedAtMs = log.TimestampMs
105+
next.RequestVersion = log.RequestVersion
106+
next.StatusTimestampMs = log.TimestampMs
107+
next.WinnerTerminalVersion = isTerminalVersion(log)
108+
if entity.IsRequestStateTerminal(entity.RequestState(string(log.Status))) {
109+
next.CompletedAtMs = log.TimestampMs
110+
} else {
111+
next.CompletedAtMs = 0
112+
}
113+
return next
114+
}
115+
116+
func shouldReplaceWinner(existing entity.RequestSummary, log entity.RequestLog) bool {
117+
incomingTerminalVersion := isTerminalVersion(log)
118+
if incomingTerminalVersion {
119+
if !existing.WinnerTerminalVersion {
120+
return true
121+
}
122+
return log.RequestVersion > existing.RequestVersion || (log.RequestVersion == existing.RequestVersion && log.TimestampMs > existing.StatusTimestampMs)
123+
}
124+
if existing.WinnerTerminalVersion {
125+
return false
126+
}
127+
return log.TimestampMs > existing.StatusTimestampMs
128+
}
129+
130+
func isTerminalVersion(log entity.RequestLog) bool {
131+
return log.RequestVersion > 0 && entity.IsRequestStateTerminal(entity.RequestState(string(log.Status)))
132+
}
133+
134+
func cloneMetadata(metadata map[string]string) map[string]string {
135+
if metadata == nil {
136+
return map[string]string{}
137+
}
138+
clone := make(map[string]string, len(metadata))
139+
for key, value := range metadata {
140+
clone[key] = value
141+
}
142+
return clone
143+
}
144+
145+
func equalRequestContext(left, right entity.RequestContext) bool {
146+
return left.RequestID == right.RequestID &&
147+
left.Queue == right.Queue &&
148+
left.AdmittedAtMs == right.AdmittedAtMs &&
149+
reflect.DeepEqual(left.ChangeURIs, right.ChangeURIs)
150+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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 request
16+
17+
import (
18+
"context"
19+
"testing"
20+
21+
"github.com/stretchr/testify/require"
22+
"github.com/uber/submitqueue/submitqueue/entity"
23+
"github.com/uber/submitqueue/submitqueue/extension/storage"
24+
storagemock "github.com/uber/submitqueue/submitqueue/extension/storage/mock"
25+
"go.uber.org/mock/gomock"
26+
)
27+
28+
func TestCreateContext(t *testing.T) {
29+
requestContext := entity.RequestContext{
30+
RequestID: "q/1",
31+
Queue: "q",
32+
ChangeURIs: []string{"github://uber/repo/pull/1/abc"},
33+
AdmittedAtMs: 100,
34+
}
35+
36+
tests := []struct {
37+
name string
38+
stored entity.RequestContext
39+
wantErr bool
40+
setupCreate func(*storagemock.MockRequestContextStore)
41+
}{
42+
{
43+
name: "new context",
44+
setupCreate: func(contextStore *storagemock.MockRequestContextStore) {
45+
contextStore.EXPECT().Create(gomock.Any(), requestContext).Return(nil)
46+
},
47+
},
48+
{
49+
name: "identical retry",
50+
stored: requestContext,
51+
setupCreate: func(contextStore *storagemock.MockRequestContextStore) {
52+
contextStore.EXPECT().Create(gomock.Any(), requestContext).Return(storage.ErrAlreadyExists)
53+
contextStore.EXPECT().Get(gomock.Any(), requestContext.RequestID).Return(requestContext, nil)
54+
},
55+
},
56+
{
57+
name: "conflicting retry",
58+
stored: entity.RequestContext{
59+
RequestID: requestContext.RequestID,
60+
Queue: "other",
61+
},
62+
wantErr: true,
63+
setupCreate: func(contextStore *storagemock.MockRequestContextStore) {
64+
contextStore.EXPECT().Create(gomock.Any(), requestContext).Return(storage.ErrAlreadyExists)
65+
contextStore.EXPECT().Get(gomock.Any(), requestContext.RequestID).Return(entity.RequestContext{RequestID: requestContext.RequestID, Queue: "other"}, nil)
66+
},
67+
},
68+
}
69+
70+
for _, tt := range tests {
71+
t.Run(tt.name, func(t *testing.T) {
72+
ctrl := gomock.NewController(t)
73+
contextStore := storagemock.NewMockRequestContextStore(ctrl)
74+
tt.setupCreate(contextStore)
75+
summaryStore := storagemock.NewMockRequestSummaryStore(ctrl)
76+
if !tt.wantErr {
77+
summaryStore.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil)
78+
}
79+
store := storagemock.NewMockStorage(ctrl)
80+
store.EXPECT().GetRequestContextStore().Return(contextStore).AnyTimes()
81+
if !tt.wantErr {
82+
store.EXPECT().GetRequestSummaryStore().Return(summaryStore)
83+
}
84+
85+
err := CreateContext(context.Background(), store, requestContext)
86+
if tt.wantErr {
87+
require.Error(t, err)
88+
return
89+
}
90+
require.NoError(t, err)
91+
})
92+
}
93+
}

submitqueue/entity/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ go_library(
1818
"request.go",
1919
"request_context.go",
2020
"request_log.go",
21+
"request_summary.go",
2122
"speculation_tree.go",
2223
],
2324
importpath = "github.com/uber/submitqueue/submitqueue/entity",
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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 entity
16+
17+
// RequestSummary is the gateway-owned current List projection.
18+
type RequestSummary struct {
19+
RequestID string
20+
Queue string
21+
ChangeURIs []string
22+
Status RequestStatus
23+
LastError string
24+
Metadata map[string]string
25+
StartedAtMs int64
26+
UpdatedAtMs int64
27+
CompletedAtMs int64
28+
RequestVersion int32
29+
StatusTimestampMs int64
30+
WinnerTerminalVersion bool
31+
Version int64
32+
}

submitqueue/extension/storage/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ go_library(
1010
"request_context_store.go",
1111
"request_log_store.go",
1212
"request_store.go",
13+
"request_summary_store.go",
1314
"speculation_tree_store.go",
1415
"storage.go",
1516
],

submitqueue/extension/storage/mock/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ go_library(
1010
"request_context_store_mock.go",
1111
"request_log_store_mock.go",
1212
"request_store_mock.go",
13+
"request_summary_store_mock.go",
1314
"speculation_tree_store_mock.go",
1415
"storage_mock.go",
1516
],

0 commit comments

Comments
 (0)