Skip to content

Commit 309f8db

Browse files
committed
feat(gateway): persist request receipts on Land
Create authoritative request summaries, exact change URI mappings, and queue receipt projections before publishing accepted Land requests. Validation: make fmt && make build && make test && make e2e-test
1 parent fe18d68 commit 309f8db

9 files changed

Lines changed: 554 additions & 45 deletions

File tree

submitqueue/core/request/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go_library(
44
name = "go_default_library",
55
srcs = [
66
"log.go",
7+
"receipt.go",
78
"request.go",
89
],
910
importpath = "github.com/uber/submitqueue/submitqueue/core/request",
@@ -21,6 +22,7 @@ go_test(
2122
name = "go_default_test",
2223
srcs = [
2324
"log_test.go",
25+
"receipt_test.go",
2426
"request_test.go",
2527
],
2628
embed = [":go_default_library"],
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
"fmt"
20+
21+
"github.com/uber/submitqueue/submitqueue/entity"
22+
"github.com/uber/submitqueue/submitqueue/extension/storage"
23+
)
24+
25+
// ReceiptWriter stores an internal request receipt before pipeline publication.
26+
type ReceiptWriter struct {
27+
store storage.Storage
28+
}
29+
30+
// NewReceiptWriter creates a request receipt writer.
31+
func NewReceiptWriter(store storage.Storage) *ReceiptWriter {
32+
return &ReceiptWriter{store: store}
33+
}
34+
35+
// Create writes the authoritative request receipt.
36+
func (w *ReceiptWriter) Create(ctx context.Context, summary entity.RequestSummary) error {
37+
if err := w.store.GetRequestSummaryStore().Create(ctx, summary); err != nil {
38+
return fmt.Errorf("failed to create request summary request_id=%s: %w", summary.RequestID, err)
39+
}
40+
return nil
41+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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 TestReceiptWriter_Create(t *testing.T) {
29+
summary := testRequestSummary()
30+
tests := []struct {
31+
name string
32+
setup func(*gomock.Controller, *storagemock.MockStorage)
33+
wantError bool
34+
}{
35+
{
36+
name: "creates receipt",
37+
setup: func(ctrl *gomock.Controller, store *storagemock.MockStorage) {
38+
summaryStore := storagemock.NewMockRequestSummaryStore(ctrl)
39+
store.EXPECT().GetRequestSummaryStore().Return(summaryStore).AnyTimes()
40+
summaryStore.EXPECT().Create(gomock.Any(), summary).Return(nil)
41+
},
42+
},
43+
{
44+
name: "summary failure stops remaining writes",
45+
setup: func(ctrl *gomock.Controller, store *storagemock.MockStorage) {
46+
summaryStore := storagemock.NewMockRequestSummaryStore(ctrl)
47+
store.EXPECT().GetRequestSummaryStore().Return(summaryStore).AnyTimes()
48+
summaryStore.EXPECT().Create(gomock.Any(), summary).Return(storage.ErrAlreadyExists)
49+
},
50+
wantError: true,
51+
},
52+
}
53+
54+
for _, tt := range tests {
55+
t.Run(tt.name, func(t *testing.T) {
56+
ctrl := gomock.NewController(t)
57+
store := storagemock.NewMockStorage(ctrl)
58+
tt.setup(ctrl, store)
59+
err := NewReceiptWriter(store).Create(context.Background(), summary)
60+
if tt.wantError {
61+
require.Error(t, err)
62+
} else {
63+
require.NoError(t, err)
64+
}
65+
})
66+
}
67+
}
68+
69+
func testRequestSummary() entity.RequestSummary {
70+
return entity.RequestSummary{
71+
RequestID: "q/1", Queue: "q", ChangeURIs: []string{"uri/1", "uri/2"}, ReceivedAtMs: 10,
72+
Status: entity.RequestStatusAccepting, StatusTimestampMs: 10, Version: 1, Metadata: map[string]string{},
73+
}
74+
}

submitqueue/entity/request_log.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ const (
3131
// RequestStatusUnknown is the unknown sentinel status. It is set by default when the structure is initialized. It should never be seen in the system.
3232
RequestStatusUnknown RequestStatus = ""
3333

34-
// RequestStatusAccepted indicates that the request has been accepted by the system. Typically a gateway service will set this status when the land request is received and persisted to the logging database.
34+
// RequestStatusAccepting is the internal status of a persisted Land receipt that has not yet been published to the processing pipeline.
35+
// Public read APIs must not expose requests that remain in this status.
36+
RequestStatusAccepting RequestStatus = "accepting"
37+
38+
// RequestStatusAccepted indicates that the request has been published to the processing pipeline.
3539
RequestStatusAccepted RequestStatus = "accepted"
3640

3741
// RequestStatusStarted is the initial status of a request. It corresponds to the RequestStateStarted state and typically set by the orchestrator service when the request is received and persisted to the operating database.

submitqueue/gateway/controller/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ go_library(
66
"cancel.go",
77
"land.go",
88
"ping.go",
9+
"read_errors.go",
910
"status.go",
1011
],
1112
importpath = "github.com/uber/submitqueue/submitqueue/gateway/controller",
@@ -34,6 +35,7 @@ go_test(
3435
"land_test.go",
3536
"ping_test.go",
3637
"status_test.go",
38+
"storage_fixture_test.go",
3739
],
3840
embed = [":go_default_library"],
3941
deps = [

submitqueue/gateway/controller/land.go

Lines changed: 59 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ import (
1818
"context"
1919
"errors"
2020
"fmt"
21+
"time"
2122

2223
"github.com/uber-go/tally"
2324
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
2425
"github.com/uber/submitqueue/platform/consumer"
2526
"github.com/uber/submitqueue/platform/errs"
2627
"github.com/uber/submitqueue/platform/extension/counter"
2728
"github.com/uber/submitqueue/platform/metrics"
29+
requestcore "github.com/uber/submitqueue/submitqueue/core/request"
2830
"github.com/uber/submitqueue/submitqueue/core/topickey"
2931
"github.com/uber/submitqueue/submitqueue/entity"
3032
"github.com/uber/submitqueue/submitqueue/extension/queueconfig"
@@ -61,25 +63,27 @@ func IsUnrecognizedQueue(err error) bool {
6163

6264
// LandController handles land business logic for the gateway
6365
type LandController struct {
64-
logger *zap.SugaredLogger
65-
metricsScope tally.Scope
66-
counter counter.Counter
67-
store storage.Storage
68-
queueConfigs queueconfig.Store
69-
registry consumer.TopicRegistry
66+
logger *zap.SugaredLogger
67+
metricsScope tally.Scope
68+
counter counter.Counter
69+
store storage.Storage
70+
receiptWriter *requestcore.ReceiptWriter
71+
queueConfigs queueconfig.Store
72+
registry consumer.TopicRegistry
7073
}
7174

7275
// NewLandController creates a new instance of the gateway land controller.
7376
// The controller publishes land requests to the topic registered under
7477
// topickey.TopicKeyStart in the registry.
7578
func NewLandController(logger *zap.SugaredLogger, scope tally.Scope, counter counter.Counter, store storage.Storage, queueConfigs queueconfig.Store, registry consumer.TopicRegistry) *LandController {
7679
return &LandController{
77-
logger: logger,
78-
metricsScope: scope.SubScope("land_controller"),
79-
counter: counter,
80-
store: store,
81-
queueConfigs: queueConfigs,
82-
registry: registry,
80+
logger: logger,
81+
metricsScope: scope.SubScope("land_controller"),
82+
counter: counter,
83+
store: store,
84+
receiptWriter: requestcore.NewReceiptWriter(store),
85+
queueConfigs: queueConfigs,
86+
registry: registry,
8387
}
8488
}
8589

@@ -90,12 +94,12 @@ func (c *LandController) Land(ctx context.Context, req entity.LandRequest) (resu
9094
op := metrics.Begin(c.metricsScope, opName)
9195
defer func() { op.Complete(retErr) }()
9296

93-
// Validate required fields.
94-
if req.Queue == "" {
95-
return entity.LandResult{}, fmt.Errorf("LandController requires the request to have a queue name specified: %w", ErrInvalidRequest)
97+
// Validate provider-agnostic request constraints before allocating an sqid.
98+
if err := validateQueueIdentifier(req.Queue); err != nil {
99+
return entity.LandResult{}, fmt.Errorf("LandController invalid queue: %w", err)
96100
}
97-
if len(req.Change.URIs) == 0 {
98-
return entity.LandResult{}, fmt.Errorf("LandController requires the request to have at least one change URI specified: %w", ErrInvalidRequest)
101+
if err := validateChangeURIs(req.Change.URIs); err != nil {
102+
return entity.LandResult{}, fmt.Errorf("LandController invalid change URIs: %w", err)
99103
}
100104

101105
queue := req.Queue
@@ -113,13 +117,46 @@ func (c *LandController) Land(ctx context.Context, req entity.LandRequest) (resu
113117
return entity.LandResult{}, fmt.Errorf("LandController failed to generate request ID for queue=%s: %w", queue, err)
114118
}
115119
req.ID = fmt.Sprintf("%s/%d", queue, seq)
120+
if err := validateStoredIdentifier("generated sqid", req.ID); err != nil {
121+
return entity.LandResult{}, fmt.Errorf("LandController generated invalid request ID for queue=%s: %w", queue, err)
122+
}
123+
124+
receivedAtMs := time.Now().UnixMilli()
125+
summary := entity.RequestSummary{
126+
RequestID: req.ID,
127+
Queue: req.Queue,
128+
ChangeURIs: append([]string{}, req.Change.URIs...),
129+
ReceivedAtMs: receivedAtMs,
130+
Status: entity.RequestStatusAccepting,
131+
StatusTimestampMs: receivedAtMs,
132+
Version: 1,
133+
Metadata: map[string]string{},
134+
}
135+
if err := c.receiptWriter.Create(ctx, summary); err != nil {
136+
return entity.LandResult{}, fmt.Errorf("LandController failed to create request receipt sqid=%s: %w", req.ID, err)
137+
}
116138

117-
// Record the accepted status in the request log for reconciliation. Once the request materializes as a Request entity, the status might be updated to "new".
118-
// It is important to record the status before publishing to the queue for processing. It is important to publish straight to the database and not via a entityqueue.
119-
// Gateway has to stay consistent with the request log.
120-
logEntry := entity.NewRequestLog(req.ID, entity.RequestStatusAccepted, 0, "", nil)
139+
// Publish before exposing the request as accepted. A failed publish leaves an
140+
// internal accepting receipt that public read APIs do not expose.
141+
if err := c.publishToQueue(ctx, req); err != nil {
142+
return entity.LandResult{}, fmt.Errorf("LandController failed to publish request to queue: %w", err)
143+
}
144+
145+
logEntry := entity.RequestLog{
146+
RequestID: req.ID,
147+
TimestampMs: receivedAtMs,
148+
Status: entity.RequestStatusAccepted,
149+
Metadata: map[string]string{},
150+
}
121151
if err := c.store.GetRequestLogStore().Insert(ctx, logEntry); err != nil {
122-
return entity.LandResult{}, fmt.Errorf("LandController failed to insert request log for sqid=%s: %w", req.ID, err)
152+
// Publication is the Land success boundary. Returning an error here would
153+
// encourage the caller to submit a duplicate request that is already queued.
154+
c.logger.Errorw("failed to record accepted status after publishing request",
155+
"queue", req.Queue,
156+
"sqid", req.ID,
157+
"error", err,
158+
)
159+
metrics.NamedCounter(c.metricsScope, opName, "accepted_log_failure", 1)
123160
}
124161

125162
c.logger.Debugw("land request created",
@@ -130,11 +167,6 @@ func (c *LandController) Land(ctx context.Context, req entity.LandRequest) (resu
130167
"strategy", string(req.LandStrategy),
131168
)
132169

133-
// Publish to queue for async processing
134-
if err := c.publishToQueue(ctx, req); err != nil {
135-
return entity.LandResult{}, fmt.Errorf("LandController failed to publish request to queue: %w", err)
136-
}
137-
138170
c.logger.Infow("request published to queue",
139171
"queue", req.Queue,
140172
"sqid", req.ID,

0 commit comments

Comments
 (0)