Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions stovepipe/entity/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ go_library(
"queue_config.go",
"request.go",
"request_id.go",
"request_log.go",
"validation_fact.go",
],
importpath = "github.com/uber/submitqueue/stovepipe/entity",
Expand All @@ -20,6 +21,7 @@ go_test(
srcs = [
"build_test.go",
"request_id_test.go",
"request_log_test.go",
"request_test.go",
"validation_fact_test.go",
],
Expand Down
153 changes: 153 additions & 0 deletions stovepipe/entity/request_log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) 2025 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package entity

import "fmt"

// RequestEvent identifies a retained occurrence that does not change request state.
type RequestEvent string

const (
// RequestEventUnknown is the unset event value.
RequestEventUnknown RequestEvent = ""
// RequestEventBuildTriggered records that a build was durably accepted.
RequestEventBuildTriggered RequestEvent = "build_triggered"
// RequestEventBuildFinished records that a build first reached a terminal status.
RequestEventBuildFinished RequestEvent = "build_finished"
// RequestEventValidationFactRecorded records that an immutable validation verdict was established.
RequestEventValidationFactRecorded RequestEvent = "validation_fact_recorded"
)

// RequestOutcomeReason identifies the durable domain reason for a terminal request state.
type RequestOutcomeReason string

const (
// RequestOutcomeReasonUnknown is the unset outcome reason.
RequestOutcomeReasonUnknown RequestOutcomeReason = ""
// RequestOutcomeReasonBuildSucceeded indicates that the request's build succeeded.
RequestOutcomeReasonBuildSucceeded RequestOutcomeReason = "build_succeeded"
// RequestOutcomeReasonBuildFailed indicates that the request's build failed.
RequestOutcomeReasonBuildFailed RequestOutcomeReason = "build_failed"
// RequestOutcomeReasonBuildCancelled indicates that the request's build was cancelled.
RequestOutcomeReasonBuildCancelled RequestOutcomeReason = "build_cancelled"
// RequestOutcomeReasonProcessingFailed indicates that validation could not be prepared.
RequestOutcomeReasonProcessingFailed RequestOutcomeReason = "processing_failed"
// RequestOutcomeReasonBuildPollingExhausted indicates that build status could not be resolved.
RequestOutcomeReasonBuildPollingExhausted RequestOutcomeReason = "build_polling_exhausted"
// RequestOutcomeReasonValidationTimeout indicates that validation exceeded its allowed duration.
RequestOutcomeReasonValidationTimeout RequestOutcomeReason = "validation_timeout"
// RequestOutcomeReasonSupersededByNewerHead indicates that a newer request replaced this one.
RequestOutcomeReasonSupersededByNewerHead RequestOutcomeReason = "superseded_by_newer_head"
)

// RequestLog is one immutable request state or explanatory lifecycle occurrence.
type RequestLog struct {
// ID is the stable opaque identity of the occurrence within the request.
ID string `json:"id"`
// Queue is the logical queue containing the request and scopes RequestID.
Queue string `json:"queue"`
// RequestID identifies the request whose log contains this record.
RequestID string `json:"request_id"`
// TimestampMs is the occurrence time in Unix milliseconds.
TimestampMs int64 `json:"timestamp_ms"`
// State is the durable request state recorded by a state record and is unset on an event record.
State RequestState `json:"state"`
// Event identifies the occurrence recorded by an event record and is unset on a state record.
Event RequestEvent `json:"event"`
// RequestVersion is the durable request version recorded by a state record and is zero on an event record.
RequestVersion int32 `json:"request_version"`
// OutcomeReason is the durable domain reason for a terminal request state and is otherwise unset.
OutcomeReason RequestOutcomeReason `json:"outcome_reason"`
// Metadata contains optional occurrence context; nil and empty maps are equivalent.
Metadata map[string]string `json:"metadata"`
}

// Validate verifies the invariants required for a newly persisted request log.
func (e RequestLog) Validate() error {
if e.ID == "" {
return fmt.Errorf("request log ID must not be empty")
}
if e.Queue == "" {
return fmt.Errorf("request log queue must not be empty")
}
if e.RequestID == "" {
return fmt.Errorf("request log request ID must not be empty")
}
if e.TimestampMs <= 0 {
return fmt.Errorf("request log timestamp must be positive")
}
if (e.State == RequestStateUnknown) == (e.Event == RequestEventUnknown) {
return fmt.Errorf("request log must contain exactly one of state and event")
}
if e.State != RequestStateUnknown {
return e.validateState()
}
return e.validateEvent()
}

func (e RequestLog) validateState() error {
if e.RequestVersion <= 0 {
return fmt.Errorf("state log must have a positive request version")
}
switch e.State {
case RequestStateAccepted, RequestStateProcessing:
if e.OutcomeReason != RequestOutcomeReasonUnknown {
return fmt.Errorf("non-terminal state log must not contain terminal context")
}
case RequestStateSuperseded:
if e.OutcomeReason != RequestOutcomeReasonSupersededByNewerHead {
return fmt.Errorf("superseded state log has invalid outcome context")
}
case RequestStateSucceeded:
if e.OutcomeReason != RequestOutcomeReasonBuildSucceeded {
return fmt.Errorf("succeeded state log has invalid outcome context")
}
case RequestStateFailed:
if !isFailureReason(e.OutcomeReason) {
return fmt.Errorf("failed state log has invalid outcome context")
}
case RequestStateCancelled:
if e.OutcomeReason != RequestOutcomeReasonBuildCancelled {
return fmt.Errorf("cancelled state log has invalid outcome context")
}
default:
return fmt.Errorf("unknown request state %q", e.State)
}
return nil
}

func (e RequestLog) validateEvent() error {
if e.RequestVersion != 0 || e.OutcomeReason != RequestOutcomeReasonUnknown {
return fmt.Errorf("event log must not contain request-state context")
}
switch e.Event {
case RequestEventBuildTriggered, RequestEventBuildFinished, RequestEventValidationFactRecorded:
default:
return fmt.Errorf("unknown request event %q", e.Event)
}
return nil
}

func isFailureReason(reason RequestOutcomeReason) bool {
switch reason {
case RequestOutcomeReasonBuildFailed,
RequestOutcomeReasonProcessingFailed,
RequestOutcomeReasonBuildPollingExhausted,
RequestOutcomeReasonValidationTimeout:
return true
default:
return false
}
}
195 changes: 195 additions & 0 deletions stovepipe/entity/request_log_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// Copyright (c) 2025 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package entity

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestRequestLogValidate(t *testing.T) {
base := RequestLog{
ID: "state/1",
Queue: "monorepo/main",
RequestID: "request/monorepo/main/1",
TimestampMs: 1735689600000,
State: RequestStateAccepted,
RequestVersion: 1,
}

tests := []struct {
name string
mutate func(RequestLog) RequestLog
wantErr bool
}{
{name: "accepted state"},
{
name: "superseded state",
mutate: func(entry RequestLog) RequestLog {
entry.State = RequestStateSuperseded
entry.Metadata = map[string]string{"superseded_by_request_id": "request/monorepo/main/2"}
entry.OutcomeReason = RequestOutcomeReasonSupersededByNewerHead
return entry
},
},
{
name: "failed without build",
mutate: func(entry RequestLog) RequestLog {
entry.State = RequestStateFailed
entry.OutcomeReason = RequestOutcomeReasonProcessingFailed
return entry
},
},
{
name: "succeeded state",
mutate: func(entry RequestLog) RequestLog {
entry.State = RequestStateSucceeded
entry.Metadata = map[string]string{"build_id": "42"}
entry.OutcomeReason = RequestOutcomeReasonBuildSucceeded
return entry
},
},
{
name: "failed build state",
mutate: func(entry RequestLog) RequestLog {
entry.State = RequestStateFailed
entry.Metadata = map[string]string{"build_id": "42"}
entry.OutcomeReason = RequestOutcomeReasonBuildFailed
return entry
},
},
{
name: "failed polling state",
mutate: func(entry RequestLog) RequestLog {
entry.State = RequestStateFailed
entry.OutcomeReason = RequestOutcomeReasonBuildPollingExhausted
return entry
},
},
{
name: "failed timeout state",
mutate: func(entry RequestLog) RequestLog {
entry.State = RequestStateFailed
entry.OutcomeReason = RequestOutcomeReasonValidationTimeout
return entry
},
},
{
name: "cancelled state",
mutate: func(entry RequestLog) RequestLog {
entry.State = RequestStateCancelled
entry.Metadata = map[string]string{"build_id": "42"}
entry.OutcomeReason = RequestOutcomeReasonBuildCancelled
return entry
},
},
{
name: "validation fact with green degree",
mutate: func(entry RequestLog) RequestLog {
entry.State = RequestStateUnknown
entry.Event = RequestEventValidationFactRecorded
entry.RequestVersion = 0
entry.Metadata = map[string]string{"fact_degree": "0"}
return entry
},
},
{
name: "build event",
mutate: func(entry RequestLog) RequestLog {
entry.State = RequestStateUnknown
entry.Event = RequestEventBuildTriggered
entry.RequestVersion = 0
entry.Metadata = map[string]string{"build_id": "42"}
return entry
},
},
{
name: "build finished event",
mutate: func(entry RequestLog) RequestLog {
entry.State = RequestStateUnknown
entry.Event = RequestEventBuildFinished
entry.RequestVersion = 0
entry.Metadata = map[string]string{"build_id": "42"}
return entry
},
},
{name: "missing ID", mutate: func(entry RequestLog) RequestLog { entry.ID = ""; return entry }, wantErr: true},
{name: "missing queue", mutate: func(entry RequestLog) RequestLog { entry.Queue = ""; return entry }, wantErr: true},
{name: "missing request ID", mutate: func(entry RequestLog) RequestLog { entry.RequestID = ""; return entry }, wantErr: true},
{name: "non-positive timestamp", mutate: func(entry RequestLog) RequestLog { entry.TimestampMs = 0; return entry }, wantErr: true},
{name: "missing occurrence", mutate: func(entry RequestLog) RequestLog { entry.State = RequestStateUnknown; return entry }, wantErr: true},
{name: "two occurrences", mutate: func(entry RequestLog) RequestLog {
entry.Event = RequestEventBuildTriggered
return entry
}, wantErr: true},
{name: "state without version", mutate: func(entry RequestLog) RequestLog { entry.RequestVersion = 0; return entry }, wantErr: true},
{name: "unknown state", mutate: func(entry RequestLog) RequestLog {
entry.State = RequestState("future")
return entry
}, wantErr: true},
{name: "non-terminal state with outcome", mutate: func(entry RequestLog) RequestLog {
entry.OutcomeReason = RequestOutcomeReasonProcessingFailed
return entry
}, wantErr: true},
{name: "opaque metadata", mutate: func(entry RequestLog) RequestLog {
entry.Metadata = map[string]string{"arbitrary": "value"}
return entry
}},
{
name: "failed without reason",
mutate: func(entry RequestLog) RequestLog {
entry.State = RequestStateFailed
return entry
},
wantErr: true,
},
{
name: "event with request version",
mutate: func(entry RequestLog) RequestLog {
entry.State = RequestStateUnknown
entry.Event = RequestEventBuildTriggered
entry.Metadata = map[string]string{"build_id": "42"}
return entry
},
wantErr: true,
},
{
name: "unknown event",
mutate: func(entry RequestLog) RequestLog {
entry.State = RequestStateUnknown
entry.Event = RequestEvent("future")
entry.RequestVersion = 0
return entry
},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entry := base
if tt.mutate != nil {
entry = tt.mutate(entry)
}
err := entry.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
1 change: 1 addition & 0 deletions stovepipe/extension/storage/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go_library(
srcs = [
"build_store.go",
"queue_store.go",
"request_log_store.go",
"request_store.go",
"request_uri_store.go",
"storage.go",
Expand Down
1 change: 1 addition & 0 deletions stovepipe/extension/storage/mock/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go_library(
srcs = [
"build_store_mock.go",
"queue_store_mock.go",
"request_log_store_mock.go",
"request_store_mock.go",
"request_uri_store_mock.go",
"storage_mock.go",
Expand Down
Loading
Loading