Skip to content
Open
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 @@ -8,6 +8,7 @@ go_library(
"queue.go",
"queue_config.go",
"request.go",
"request_history.go",
"request_id.go",
"validation_fact.go",
],
Expand All @@ -19,6 +20,7 @@ go_test(
name = "go_default_test",
srcs = [
"build_test.go",
"request_history_test.go",
"request_id_test.go",
"request_test.go",
"validation_fact_test.go",
Expand Down
175 changes: 175 additions & 0 deletions stovepipe/entity/request_history.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// 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"
"math"
)

// 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"
)

// RequestHistoryEntry is one immutable request state or explanatory lifecycle occurrence.
type RequestHistoryEntry 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 history contains this entry.
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 entry and is unset on an event entry.
State RequestState `json:"state"`
// Event identifies the occurrence recorded by an event entry and is unset on a state entry.
Event RequestEvent `json:"event"`
// RequestVersion is the durable request version recorded by a state entry and is zero on an event entry.
RequestVersion int32 `json:"request_version"`
// SupersededByRequestID identifies the newer request responsible for supersession and is otherwise empty.
SupersededByRequestID string `json:"superseded_by_request_id"`
// BuildID identifies the build associated with the occurrence and is empty when no build applies.
BuildID string `json:"build_id"`
// OutcomeReason is the durable domain reason for a terminal request state and is otherwise unset.
OutcomeReason RequestOutcomeReason `json:"outcome_reason"`
// FactDegree is the validation degree recorded by a validation-fact event; its event kind distinguishes zero from absence.
FactDegree float64 `json:"fact_degree"`
}

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

func (e RequestHistoryEntry) validateState() error {
if e.RequestVersion <= 0 {
return fmt.Errorf("state history entry must have a positive request version")
}
if e.FactDegree != 0 {
return fmt.Errorf("state history entry must not contain a validation degree")
}

switch e.State {
case RequestStateAccepted, RequestStateProcessing:
if e.SupersededByRequestID != "" || e.BuildID != "" || e.OutcomeReason != RequestOutcomeReasonUnknown {
return fmt.Errorf("non-terminal state history entry must not contain terminal context")
}
case RequestStateSuperseded:
if e.SupersededByRequestID == "" {
return fmt.Errorf("superseded state history entry must identify the newer request")
}
if e.BuildID != "" || e.OutcomeReason != RequestOutcomeReasonSupersededByNewerHead {
return fmt.Errorf("superseded state history entry has invalid outcome context")
}
case RequestStateSucceeded:
if e.SupersededByRequestID != "" || e.BuildID == "" || e.OutcomeReason != RequestOutcomeReasonBuildSucceeded {
return fmt.Errorf("succeeded state history entry has invalid outcome context")
}
case RequestStateFailed:
if e.SupersededByRequestID != "" || !isFailureReason(e.OutcomeReason) {
return fmt.Errorf("failed state history entry has invalid outcome context")
}
case RequestStateCancelled:
if e.SupersededByRequestID != "" || e.BuildID == "" || e.OutcomeReason != RequestOutcomeReasonBuildCancelled {
return fmt.Errorf("cancelled state history entry has invalid outcome context")
}
default:
return fmt.Errorf("unknown request state %q", e.State)
}
return nil
}

func (e RequestHistoryEntry) validateEvent() error {
if e.RequestVersion != 0 || e.SupersededByRequestID != "" || e.OutcomeReason != RequestOutcomeReasonUnknown {
return fmt.Errorf("event history entry must not contain request-state context")
}

switch e.Event {
case RequestEventBuildTriggered, RequestEventBuildFinished:
if e.BuildID == "" || e.FactDegree != 0 {
return fmt.Errorf("build history event has invalid context")
}
case RequestEventValidationFactRecorded:
if e.BuildID != "" || math.IsNaN(e.FactDegree) || math.IsInf(e.FactDegree, 0) || e.FactDegree < DegreeGreen || e.FactDegree > DegreeBroken {
return fmt.Errorf("validation-fact history event has invalid context")
}
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
}
}
Loading
Loading