|
| 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