Skip to content

Commit e9e663b

Browse files
committed
feat(stovepipe): persist request log records
1 parent cb5d781 commit e9e663b

16 files changed

Lines changed: 1167 additions & 0 deletions

File tree

stovepipe/entity/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ go_library(
99
"queue_config.go",
1010
"request.go",
1111
"request_id.go",
12+
"request_log.go",
1213
"validation_fact.go",
1314
],
1415
importpath = "github.com/uber/submitqueue/stovepipe/entity",
@@ -20,6 +21,7 @@ go_test(
2021
srcs = [
2122
"build_test.go",
2223
"request_id_test.go",
24+
"request_log_test.go",
2325
"request_test.go",
2426
"validation_fact_test.go",
2527
],

stovepipe/entity/request_log.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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+
import (
18+
"fmt"
19+
"math"
20+
)
21+
22+
// RequestEvent identifies a retained occurrence that does not change request state.
23+
type RequestEvent string
24+
25+
const (
26+
// RequestEventUnknown is the unset event value.
27+
RequestEventUnknown RequestEvent = ""
28+
// RequestEventBuildTriggered records that a build was durably accepted.
29+
RequestEventBuildTriggered RequestEvent = "build_triggered"
30+
// RequestEventBuildFinished records that a build first reached a terminal status.
31+
RequestEventBuildFinished RequestEvent = "build_finished"
32+
// RequestEventValidationFactRecorded records that an immutable validation verdict was established.
33+
RequestEventValidationFactRecorded RequestEvent = "validation_fact_recorded"
34+
)
35+
36+
// RequestOutcomeReason identifies the durable domain reason for a terminal request state.
37+
type RequestOutcomeReason string
38+
39+
const (
40+
// RequestOutcomeReasonUnknown is the unset outcome reason.
41+
RequestOutcomeReasonUnknown RequestOutcomeReason = ""
42+
// RequestOutcomeReasonBuildSucceeded indicates that the request's build succeeded.
43+
RequestOutcomeReasonBuildSucceeded RequestOutcomeReason = "build_succeeded"
44+
// RequestOutcomeReasonBuildFailed indicates that the request's build failed.
45+
RequestOutcomeReasonBuildFailed RequestOutcomeReason = "build_failed"
46+
// RequestOutcomeReasonBuildCancelled indicates that the request's build was cancelled.
47+
RequestOutcomeReasonBuildCancelled RequestOutcomeReason = "build_cancelled"
48+
// RequestOutcomeReasonProcessingFailed indicates that validation could not be prepared.
49+
RequestOutcomeReasonProcessingFailed RequestOutcomeReason = "processing_failed"
50+
// RequestOutcomeReasonBuildPollingExhausted indicates that build status could not be resolved.
51+
RequestOutcomeReasonBuildPollingExhausted RequestOutcomeReason = "build_polling_exhausted"
52+
// RequestOutcomeReasonValidationTimeout indicates that validation exceeded its allowed duration.
53+
RequestOutcomeReasonValidationTimeout RequestOutcomeReason = "validation_timeout"
54+
// RequestOutcomeReasonSupersededByNewerHead indicates that a newer request replaced this one.
55+
RequestOutcomeReasonSupersededByNewerHead RequestOutcomeReason = "superseded_by_newer_head"
56+
)
57+
58+
// RequestLog is one immutable request state or explanatory lifecycle occurrence.
59+
type RequestLog struct {
60+
// ID is the stable opaque identity of the occurrence within the request.
61+
ID string `json:"id"`
62+
// Queue is the logical queue containing the request and scopes RequestID.
63+
Queue string `json:"queue"`
64+
// RequestID identifies the request whose log contains this record.
65+
RequestID string `json:"request_id"`
66+
// TimestampMs is the occurrence time in Unix milliseconds.
67+
TimestampMs int64 `json:"timestamp_ms"`
68+
// State is the durable request state recorded by a state record and is unset on an event record.
69+
State RequestState `json:"state"`
70+
// Event identifies the occurrence recorded by an event record and is unset on a state record.
71+
Event RequestEvent `json:"event"`
72+
// RequestVersion is the durable request version recorded by a state record and is zero on an event record.
73+
RequestVersion int32 `json:"request_version"`
74+
// SupersededByRequestID identifies the newer request responsible for supersession and is otherwise empty.
75+
SupersededByRequestID string `json:"superseded_by_request_id"`
76+
// BuildID identifies the build associated with the occurrence and is empty when no build applies.
77+
BuildID string `json:"build_id"`
78+
// OutcomeReason is the durable domain reason for a terminal request state and is otherwise unset.
79+
OutcomeReason RequestOutcomeReason `json:"outcome_reason"`
80+
// FactDegree is the validation degree recorded by a validation-fact event; its event kind distinguishes zero from absence.
81+
FactDegree float64 `json:"fact_degree"`
82+
}
83+
84+
// Validate verifies the invariants required for a newly persisted request log.
85+
func (e RequestLog) Validate() error {
86+
if e.ID == "" {
87+
return fmt.Errorf("request log ID must not be empty")
88+
}
89+
if e.Queue == "" {
90+
return fmt.Errorf("request log queue must not be empty")
91+
}
92+
if e.RequestID == "" {
93+
return fmt.Errorf("request log request ID must not be empty")
94+
}
95+
if e.TimestampMs <= 0 {
96+
return fmt.Errorf("request log timestamp must be positive")
97+
}
98+
if (e.State == RequestStateUnknown) == (e.Event == RequestEventUnknown) {
99+
return fmt.Errorf("request log must contain exactly one of state and event")
100+
}
101+
if e.State != RequestStateUnknown {
102+
return e.validateState()
103+
}
104+
return e.validateEvent()
105+
}
106+
107+
func (e RequestLog) validateState() error {
108+
if e.RequestVersion <= 0 {
109+
return fmt.Errorf("state log must have a positive request version")
110+
}
111+
if e.FactDegree != 0 {
112+
return fmt.Errorf("state log must not contain a validation degree")
113+
}
114+
115+
switch e.State {
116+
case RequestStateAccepted, RequestStateProcessing:
117+
if e.SupersededByRequestID != "" || e.BuildID != "" || e.OutcomeReason != RequestOutcomeReasonUnknown {
118+
return fmt.Errorf("non-terminal state log must not contain terminal context")
119+
}
120+
case RequestStateSuperseded:
121+
if e.SupersededByRequestID == "" {
122+
return fmt.Errorf("superseded state log must identify the newer request")
123+
}
124+
if e.BuildID != "" || e.OutcomeReason != RequestOutcomeReasonSupersededByNewerHead {
125+
return fmt.Errorf("superseded state log has invalid outcome context")
126+
}
127+
case RequestStateSucceeded:
128+
if e.SupersededByRequestID != "" || e.BuildID == "" || e.OutcomeReason != RequestOutcomeReasonBuildSucceeded {
129+
return fmt.Errorf("succeeded state log has invalid outcome context")
130+
}
131+
case RequestStateFailed:
132+
if e.SupersededByRequestID != "" || !isFailureReason(e.OutcomeReason) {
133+
return fmt.Errorf("failed state log has invalid outcome context")
134+
}
135+
case RequestStateCancelled:
136+
if e.SupersededByRequestID != "" || e.BuildID == "" || e.OutcomeReason != RequestOutcomeReasonBuildCancelled {
137+
return fmt.Errorf("cancelled state log has invalid outcome context")
138+
}
139+
default:
140+
return fmt.Errorf("unknown request state %q", e.State)
141+
}
142+
return nil
143+
}
144+
145+
func (e RequestLog) validateEvent() error {
146+
if e.RequestVersion != 0 || e.SupersededByRequestID != "" || e.OutcomeReason != RequestOutcomeReasonUnknown {
147+
return fmt.Errorf("event log must not contain request-state context")
148+
}
149+
150+
switch e.Event {
151+
case RequestEventBuildTriggered, RequestEventBuildFinished:
152+
if e.BuildID == "" || e.FactDegree != 0 {
153+
return fmt.Errorf("build log event has invalid context")
154+
}
155+
case RequestEventValidationFactRecorded:
156+
if e.BuildID != "" || math.IsNaN(e.FactDegree) || math.IsInf(e.FactDegree, 0) || e.FactDegree < DegreeGreen || e.FactDegree > DegreeBroken {
157+
return fmt.Errorf("validation-fact log event has invalid context")
158+
}
159+
default:
160+
return fmt.Errorf("unknown request event %q", e.Event)
161+
}
162+
return nil
163+
}
164+
165+
func isFailureReason(reason RequestOutcomeReason) bool {
166+
switch reason {
167+
case RequestOutcomeReasonBuildFailed,
168+
RequestOutcomeReasonProcessingFailed,
169+
RequestOutcomeReasonBuildPollingExhausted,
170+
RequestOutcomeReasonValidationTimeout:
171+
return true
172+
default:
173+
return false
174+
}
175+
}

0 commit comments

Comments
 (0)