Skip to content

Commit aa5f513

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 4493a5d commit aa5f513

6 files changed

Lines changed: 424 additions & 33 deletions

File tree

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: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ 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"
@@ -90,12 +91,12 @@ func (c *LandController) Land(ctx context.Context, req entity.LandRequest) (resu
9091
op := metrics.Begin(c.metricsScope, opName)
9192
defer func() { op.Complete(retErr) }()
9293

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)
94+
// Validate provider-agnostic request constraints before allocating an sqid.
95+
if err := validateQueueIdentifier(req.Queue); err != nil {
96+
return entity.LandResult{}, fmt.Errorf("LandController invalid queue: %w", err)
9697
}
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)
98+
if err := validateChangeURIs(req.Change.URIs); err != nil {
99+
return entity.LandResult{}, fmt.Errorf("LandController invalid change URIs: %w", err)
99100
}
100101

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

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)
136+
// Publish before exposing the request as accepted. A failed publish leaves an
137+
// internal accepting receipt that public read APIs do not expose.
138+
if err := c.publishToQueue(ctx, req); err != nil {
139+
return entity.LandResult{}, fmt.Errorf("LandController failed to publish request to queue: %w", err)
140+
}
141+
142+
logEntry := entity.RequestLog{
143+
RequestID: req.ID,
144+
TimestampMs: receivedAtMs,
145+
Status: entity.RequestStatusAccepted,
146+
Metadata: map[string]string{},
147+
}
121148
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)
149+
// Publication is the Land success boundary. Later pipeline events repair
150+
// the accepting projection even if this accepted log is not persisted. If
151+
// the client retries after losing the response, the orchestrator rejects
152+
// the new sqid as a duplicate while the original request continues.
153+
c.logger.Errorw("failed to record accepted status after publishing request",
154+
"queue", req.Queue,
155+
"sqid", req.ID,
156+
"error", err,
157+
)
158+
metrics.NamedCounter(c.metricsScope, opName, "accepted_log_failure", 1)
123159
}
124160

125161
c.logger.Debugw("land request created",
@@ -130,11 +166,6 @@ func (c *LandController) Land(ctx context.Context, req entity.LandRequest) (resu
130166
"strategy", string(req.LandStrategy),
131167
)
132168

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-
138169
c.logger.Infow("request published to queue",
139170
"queue", req.Queue,
140171
"sqid", req.ID,

submitqueue/gateway/controller/land_test.go

Lines changed: 156 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package controller
1717
import (
1818
"context"
1919
"fmt"
20+
"strings"
2021
"testing"
2122

2223
"github.com/stretchr/testify/assert"
@@ -64,14 +65,9 @@ func newTestRegistryWithNoopPublisher(t *testing.T, ctrl *gomock.Controller) con
6465
return registry
6566
}
6667

67-
// noopStorage returns a storage.Storage whose RequestLogStore.Insert
68-
// succeeds silently for any entityqueue.
68+
// noopStorage returns stateful request storage whose writes succeed.
6969
func noopStorage(ctrl *gomock.Controller) storage.Storage {
70-
logStore := storagemock.NewMockRequestLogStore(ctrl)
71-
logStore.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
72-
store := storagemock.NewMockStorage(ctrl)
73-
store.EXPECT().GetRequestLogStore().Return(logStore).AnyTimes()
74-
return store
70+
return newControllerStorageFixture(ctrl).storage
7571
}
7672

7773
// noopQueueConfigStore returns a mock queueconfig.Store that always reports
@@ -162,6 +158,48 @@ func TestLand_ReturnsErrorOnEmptyQueue(t *testing.T) {
162158
assert.True(t, IsInvalidRequest(err))
163159
}
164160

161+
func TestLand_ValidatesQueueLengthBeforeAllocatingSqid(t *testing.T) {
162+
tests := []struct {
163+
name string
164+
queue string
165+
wantError bool
166+
}{
167+
{name: "maximum length", queue: strings.Repeat("q", maxQueueIdentifierBytes)},
168+
{name: "over maximum", queue: strings.Repeat("q", maxQueueIdentifierBytes+1), wantError: true},
169+
}
170+
171+
for _, tt := range tests {
172+
t.Run(tt.name, func(t *testing.T) {
173+
ctrl := gomock.NewController(t)
174+
cnt := countermock.NewMockCounter(ctrl)
175+
if !tt.wantError {
176+
cnt.EXPECT().Next(gomock.Any(), gomock.Any()).Return(int64(1), nil)
177+
}
178+
controller := NewLandController(
179+
zap.NewNop().Sugar(),
180+
tally.NoopScope,
181+
cnt,
182+
noopStorage(ctrl),
183+
noopQueueConfigStore(ctrl),
184+
newTestRegistryWithNoopPublisher(t, ctrl),
185+
)
186+
187+
result, err := controller.Land(context.Background(), entity.LandRequest{
188+
Queue: tt.queue,
189+
Change: change.Change{URIs: []string{"uri"}},
190+
})
191+
192+
if tt.wantError {
193+
require.Error(t, err)
194+
assert.True(t, IsInvalidRequest(err))
195+
return
196+
}
197+
require.NoError(t, err)
198+
assert.Equal(t, tt.queue+"/1", result.ID)
199+
})
200+
}
201+
}
202+
165203
func TestLand_ReturnsErrorOnEmptyChangeUri(t *testing.T) {
166204
ctrl := gomock.NewController(t)
167205

@@ -179,6 +217,39 @@ func TestLand_ReturnsErrorOnEmptyChangeUri(t *testing.T) {
179217
assert.True(t, IsInvalidRequest(err))
180218
}
181219

220+
func TestLand_ReturnsErrorOnInvalidChangeURIs(t *testing.T) {
221+
tests := []struct {
222+
name string
223+
uris []string
224+
}{
225+
{name: "empty URI element", uris: []string{""}},
226+
{name: "URI exceeds storage limit", uris: []string{strings.Repeat("x", maxStorageIdentifierBytes+1)}},
227+
{name: "duplicate exact URI", uris: []string{"uri", "uri"}},
228+
}
229+
230+
for _, tt := range tests {
231+
t.Run(tt.name, func(t *testing.T) {
232+
ctrl := gomock.NewController(t)
233+
controller := NewLandController(
234+
zap.NewNop().Sugar(),
235+
tally.NoopScope,
236+
countermock.NewMockCounter(ctrl),
237+
noopStorage(ctrl),
238+
noopQueueConfigStore(ctrl),
239+
newTestRegistryWithNoopPublisher(t, ctrl),
240+
)
241+
242+
_, err := controller.Land(context.Background(), entity.LandRequest{
243+
Queue: "test-queue",
244+
Change: change.Change{URIs: tt.uris},
245+
})
246+
247+
require.Error(t, err)
248+
assert.True(t, IsInvalidRequest(err))
249+
})
250+
}
251+
}
252+
182253
func TestLand_ReturnsErrorOnZeroValueChange(t *testing.T) {
183254
ctrl := gomock.NewController(t)
184255

@@ -238,22 +309,44 @@ func TestLand_PropagatesQueueConfigStoreError(t *testing.T) {
238309
func TestLand_PublishesToQueue(t *testing.T) {
239310
var publishedTopic string
240311
var publishedMessage entityqueue.Message
312+
var persistedSummary entity.RequestSummary
313+
var persistedLog entity.RequestLog
241314

242315
ctrl := gomock.NewController(t)
243316

244317
cnt := countermock.NewMockCounter(ctrl)
245318
cnt.EXPECT().Next(gomock.Any(), gomock.Any()).Return(int64(123), nil)
246319

320+
store := storagemock.NewMockStorage(ctrl)
321+
summaryStore := storagemock.NewMockRequestSummaryStore(ctrl)
322+
logStore := storagemock.NewMockRequestLogStore(ctrl)
323+
store.EXPECT().GetRequestSummaryStore().Return(summaryStore).AnyTimes()
324+
store.EXPECT().GetRequestLogStore().Return(logStore).AnyTimes()
325+
247326
registry, publisher := newTestRegistry(t, ctrl)
248-
publisher.EXPECT().Publish(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
249-
func(ctx context.Context, topic string, msg entityqueue.Message) error {
250-
publishedTopic = topic
251-
publishedMessage = msg
252-
return nil
253-
},
327+
gomock.InOrder(
328+
summaryStore.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(
329+
func(_ context.Context, summary entity.RequestSummary) error {
330+
persistedSummary = summary
331+
return nil
332+
},
333+
),
334+
publisher.EXPECT().Publish(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
335+
func(_ context.Context, topic string, msg entityqueue.Message) error {
336+
publishedTopic = topic
337+
publishedMessage = msg
338+
return nil
339+
},
340+
),
341+
logStore.EXPECT().Insert(gomock.Any(), gomock.Any()).DoAndReturn(
342+
func(_ context.Context, log entity.RequestLog) error {
343+
persistedLog = log
344+
return nil
345+
},
346+
),
254347
)
255348

256-
controller := NewLandController(zap.NewNop().Sugar(), tally.NoopScope, cnt, noopStorage(ctrl), noopQueueConfigStore(ctrl), registry)
349+
controller := NewLandController(zap.NewNop().Sugar(), tally.NoopScope, cnt, store, noopQueueConfigStore(ctrl), registry)
257350
ctx := context.Background()
258351

259352
req := entity.LandRequest{
@@ -266,6 +359,24 @@ func TestLand_PublishesToQueue(t *testing.T) {
266359
require.NoError(t, err)
267360
assert.Equal(t, "test-queue/123", result.ID)
268361

362+
assert.Equal(t, entity.RequestSummary{
363+
RequestID: "test-queue/123",
364+
Queue: "test-queue",
365+
ChangeURIs: []string{"github://github.example.com/uber/backend/pull/456/fedcba9876543210fedcba9876543210fedcba98"},
366+
ReceivedAtMs: persistedSummary.ReceivedAtMs,
367+
Status: entity.RequestStatusAccepting,
368+
StatusTimestampMs: persistedSummary.ReceivedAtMs,
369+
Version: 1,
370+
Metadata: map[string]string{},
371+
}, persistedSummary)
372+
assert.Positive(t, persistedSummary.ReceivedAtMs)
373+
assert.Equal(t, entity.RequestLog{
374+
RequestID: "test-queue/123",
375+
TimestampMs: persistedSummary.ReceivedAtMs,
376+
Status: entity.RequestStatusAccepted,
377+
Metadata: map[string]string{},
378+
}, persistedLog)
379+
269380
// Verify message was published to the topic registered under TopicKeyStart
270381
assert.Equal(t, "start", publishedTopic)
271382
assert.Equal(t, "test-queue/123", publishedMessage.ID)
@@ -280,20 +391,48 @@ func TestLand_PublishesToQueue(t *testing.T) {
280391
assert.Equal(t, mergestrategy.MergeStrategyRebase, deserializedReq.LandStrategy)
281392
}
282393

283-
func TestLand_ContinuesWhenPublishFails(t *testing.T) {
394+
func TestLand_ReturnsErrorWhenPublishFails(t *testing.T) {
284395
ctrl := gomock.NewController(t)
285396

286397
cnt := countermock.NewMockCounter(ctrl)
287398
cnt.EXPECT().Next(gomock.Any(), gomock.Any()).Return(int64(999), nil)
288399

400+
store := storagemock.NewMockStorage(ctrl)
401+
summaryStore := storagemock.NewMockRequestSummaryStore(ctrl)
402+
store.EXPECT().GetRequestSummaryStore().Return(summaryStore)
403+
summaryStore.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, summary entity.RequestSummary) error {
404+
assert.Equal(t, entity.RequestStatusAccepting, summary.Status)
405+
return nil
406+
})
407+
289408
registry, publisher := newTestRegistry(t, ctrl)
290409
publisher.EXPECT().Publish(gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("queue unavailable"))
291410

292-
controller := NewLandController(zap.NewNop().Sugar(), tally.NoopScope, cnt, noopStorage(ctrl), noopQueueConfigStore(ctrl), registry)
411+
controller := NewLandController(zap.NewNop().Sugar(), tally.NoopScope, cnt, store, noopQueueConfigStore(ctrl), registry)
293412
ctx := context.Background()
294413

295414
_, err := controller.Land(ctx, testLandRequest("test-queue"))
296415

297-
// Should fail if publish fails
298416
require.Error(t, err)
299417
}
418+
419+
func TestLand_ReturnsSqidWhenAcceptedLogFailsAfterPublish(t *testing.T) {
420+
ctrl := gomock.NewController(t)
421+
cnt := countermock.NewMockCounter(ctrl)
422+
cnt.EXPECT().Next(gomock.Any(), gomock.Any()).Return(int64(999), nil)
423+
fixture := newControllerStorageFixture(ctrl)
424+
fixture.setLogInsertError(fmt.Errorf("log unavailable"))
425+
426+
controller := NewLandController(
427+
zap.NewNop().Sugar(),
428+
tally.NoopScope,
429+
cnt,
430+
fixture.storage,
431+
noopQueueConfigStore(ctrl),
432+
newTestRegistryWithNoopPublisher(t, ctrl),
433+
)
434+
result, err := controller.Land(context.Background(), testLandRequest("test-queue"))
435+
436+
require.NoError(t, err)
437+
assert.Equal(t, "test-queue/999", result.ID)
438+
}

0 commit comments

Comments
 (0)